From 7fef4e3fe5f8c2c3fcd893359df92fde26c73921 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Tue, 5 Mar 2024 17:45:24 +0100 Subject: [PATCH] feat: Visual editor & Formats support (#2145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Štěpán Granát --- .editorconfig | 3 + .../api/v2/controllers/ApiKeyController.kt | 3 + .../api/v2/controllers/KeyController.kt | 15 +- .../v2/controllers/V2ProjectsController.kt | 8 +- .../controllers/batch/V2ExportController.kt | 10 +- .../dataImport/ImportSettingsController.kt | 62 + .../{ => dataImport}/V2ImportController.kt | 72 +- .../TranslationSuggestionController.kt | 9 +- .../CreateOrUpdateTranslationsFacade.kt | 92 + .../translation/TranslationsController.kt | 47 +- .../tolgee/component/KeyComplexEditHelper.kt | 88 +- .../hateoas/apiKey/ApiKeyPermissionsModel.kt | 2 + .../ContentDeliveryConfigModel.kt | 5 +- .../ImportFileIssueModelAssembler.kt | 2 +- .../ImportFileIssueParamModelAssembler.kt | 2 +- .../ImportLanguageModelAssembler.kt | 2 +- .../hateoas/dataImport/ImportSettingsModel.kt | 10 + .../dataImport/ImportTranslationModel.kt | 4 + .../ImportTranslationModelAssembler.kt | 20 +- .../kotlin/io/tolgee/hateoas/key/KeyModel.kt | 2 + .../tolgee/hateoas/key/KeyModelAssembler.kt | 2 + .../io/tolgee/hateoas/key/KeyWithDataModel.kt | 6 + .../hateoas/key/KeyWithDataModelAssembler.kt | 3 + .../key/KeyWithScreenshotsModelAssembler.kt | 3 + .../io/tolgee/hateoas/project/ProjectModel.kt | 2 + .../hateoas/project/ProjectModelAssembler.kt | 3 +- .../hateoas/project/ProjectWithStatsModel.kt | 2 + .../project/ProjectWithStatsModelAssembler.kt | 3 +- .../hateoas/project/SimpleProjectModel.kt | 1 + .../project/SimpleProjectModelAssembler.kt | 22 +- .../translations/KeyWithTranslationsModel.kt | 4 + .../KeyWithTranslationsModelAssembler.kt | 2 + .../SetTranslationsResponseModel.kt | 1 + .../kotlin/io/tolgee/ExceptionHandlers.kt | 4 +- .../src/main/resources/application-e2e.yaml | 3 + .../StreamingBodyDatabasePoolHealthTest.kt | 23 +- .../v2/controllers/ApiKeyControllerTest.kt | 3 + ... TranslationSuggestionControllerMtTest.kt} | 58 +- .../TranslationSuggestionControllerTmTest.kt | 182 + .../TranslationsControllerModificationTest.kt | 120 + .../TranslationsControllerViewTest.kt | 12 + .../V2ImportControllerAddFilesTest.kt | 93 +- ...portControllerConflictsBetweenFilesTest.kt | 328 ++ .../V2ImportControllerManipulationTest.kt | 13 - .../V2ImportControllerPluralizationTest.kt | 58 + .../V2ImportControllerResultTest.kt | 30 +- ...ImportSettingsControllerApplicationTest.kt | 225 + .../ImportSettingsControllerTest.kt | 60 + ...est.kt => KeyControllerComplexEditTest.kt} | 93 +- .../v2KeyController/KeyControllerInfoTest.kt | 6 + .../KeyControllerPluralizationTest.kt | 372 ++ .../v2KeyController/KeyControllerTest.kt | 15 + .../V2ProjectsControllerCreateTest.kt | 22 +- .../V2ProjectsControllerEditTest.kt | 10 +- .../V2ProjectsControllerTest.kt | 1 + .../io/tolgee/cache/AbstractCacheTest.kt | 2 +- .../io/tolgee/service/LanguageCachingTest.kt | 4 +- .../dataImport/StoredDataImporterTest.kt | 11 + .../queryBuilders/CursorUtilUnitTest.kt | 2 + .../kotlin/io/tolgee/util/performImport.kt | 36 + .../app/src/test/resources/application.yaml | 4 +- .../test/resources/import/almost_simple.json | 3 + .../android/strings_params_everywhere.xml | 13 + .../import/apple/params_everywhere_cs.xliff | 76 + .../Localizable.strings | 3 + .../Localizable.stringsdict | 24 + backend/data/build.gradle | 1 + .../kotlin/io/tolgee/api/IImportSettings.kt | 27 + .../kotlin/io/tolgee/api/ISimpleProject.kt | 14 + .../component/KeyCustomValuesValidator.kt | 34 + .../LanguageTagConvertor.kt | 32 +- .../machineTranslation/MtServiceManager.kt | 2 + .../machineTranslation/TranslateResult.kt | 5 +- .../machineTranslation/TranslationParams.kt | 6 +- .../providers/ProviderTranslateParams.kt | 8 + .../providers/tolgee/TolgeeTranslateParams.kt | 2 + .../tolgee/TolgeeTranslationProvider.kt | 2 + .../configuration/tolgee/CacheProperties.kt | 2 +- .../tolgee/PostgresAutostartProperties.kt | 2 +- .../TolgeeMachineTranslationProperties.kt | 2 + .../kotlin/io/tolgee/constants/Message.kt | 10 +- .../io/tolgee/constants/MtServiceType.kt | 2 + .../testDataBuilder/TestDataService.kt | 8 + .../builders/ProjectBuilder.kt | 13 + .../testDataBuilder/data/KeysInfoTestData.kt | 3 + .../testDataBuilder/data/KeysTestData.kt | 1 + .../data/SuggestionTestData.kt | 34 + .../data/TranslationsTestData.kt | 22 + .../data/dataImport/ImportCleanTestData.kt | 7 +- .../dataImport/ImportPluralizationTestData.kt | 110 + .../data/dataImport/ImportTestData.kt | 25 + .../kotlin/io/tolgee/dtos/IExportParams.kt | 21 +- .../io/tolgee/dtos/cacheable/ProjectDto.kt | 15 +- .../dtos/dataImport/ImportAddFilesParams.kt | 7 + .../tolgee/dtos/dataImport/ImportFileDto.kt | 5 +- .../io/tolgee/dtos/queryResults/KeyView.kt | 1 + .../request/ContentDeliveryConfigRequest.kt | 6 +- .../tolgee/dtos/request/SuggestRequestDto.kt | 2 + .../dataImport/ImportSettingsRequest.kt | 11 + .../dtos/request/export/ExportFormat.kt | 6 - .../dtos/request/export/ExportParams.kt | 13 +- .../dtos/request/key/ComplexEditKeyDto.kt | 15 + .../tolgee/dtos/request/key/CreateKeyDto.kt | 8 + ...eProjectDTO.kt => CreateProjectRequest.kt} | 4 +- ...ditProjectDTO.kt => EditProjectRequest.kt} | 5 +- .../tolgee/formats/BaseIcuMessageConvertor.kt | 209 + .../io/tolgee/formats/CollisionHandler.kt | 10 + .../kotlin/io/tolgee/formats/ExportFormat.kt | 12 + .../io/tolgee/formats/ExportMessageFormat.kt | 7 + .../formats/FormsToIcuPluralConvertor.kt | 33 + .../tolgee/formats/FromIcuParamConvertor.kt | 16 + .../ImportFileProcessor.kt | 4 +- .../formats/ImportFileProcessorFactory.kt | 52 + .../tolgee/formats/ImportMessageConvertor.kt | 10 + .../formats/ImportMessageConvertorType.kt | 21 + .../tolgee/formats/MessageConvertorFactory.kt | 24 + .../tolgee/formats/MessageConvertorResult.kt | 3 + .../io/tolgee/formats/MessagePatternUtil.kt | 644 +++ .../formats/NoOpFromIcuParamConvertor.kt | 15 + .../formats/PossiblePluralConversionResult.kt | 21 + .../kotlin/io/tolgee/formats/StringWrapper.kt | 5 + .../io/tolgee/formats/ToIcuParamConvertor.kt | 10 + .../formats/android/androidStringsXmlModel.kt | 24 + .../android/in/AndroidStringsXmlParser.kt | 127 + .../android/in/AndroidStringsXmlProcessor.kt | 119 + .../in/AndroidToIcuMessageConvertor.kt | 69 + .../android/in/JavaToIcuParamConvertor.kt | 66 + .../android/out/AndroidStringsXmlExporter.kt | 206 + .../out/AndroidStringsXmlFileWriter.kt | 66 + .../android/out/IcuToJavaMessageConvertor.kt | 16 + .../android/out/JavaFromIcuParamConvertor.kt | 75 + .../formats/apple/appleFormatConstants.kt | 5 + .../formats/apple/in/AppleCollisionHandler.kt | 36 + .../apple/in/AppleToIcuMessageConvertor.kt | 58 + .../apple/in/AppleToIcuParamConvertor.kt | 68 + .../tolgee/formats/apple/in/langGuessUtil.kt | 5 + .../formats/apple/in/namespaceGuessUtil.kt | 12 + .../in/stringdict/StringsdictFileProcessor.kt | 166 + .../apple/in/strings/StringsFileProcessor.kt | 139 + .../apple/in/xliff/AppleXliffFileProcessor.kt | 223 + .../apple/out/AppleFromIcuParamConvertor.kt | 75 + .../out/AppleStringsStringsdictExporter.kt | 92 + .../formats/apple/out/AppleXliffExporter.kt | 302 ++ .../apple/out/AppleXliffTransUnitInfo.kt | 5 + .../apple/out/IcuToAppleMessageConvertor.kt | 16 + .../tolgee/formats/apple/out/StringsWriter.kt | 30 + .../formats/apple/out/StringsdictWriter.kt | 70 + .../kotlin/io/tolgee/formats/constants.kt | 3 + .../formats/escaping/ForceIcuEscaper.kt | 86 + .../io/tolgee/formats/escaping/IcuUnescper.kt | 81 + .../formats/escaping/PluralFormIcuEscaper.kt | 89 + .../io/tolgee/formats/flutter/contsants.kt | 3 + .../tolgee/formats/flutter/flutterArbModel.kt | 12 + .../in/FlutterArbFileParseException.kt | 3 + .../flutter/in/FlutterArbFileParser.kt | 54 + .../flutter/in/FlutterArbFileProcessor.kt | 52 + .../flutter/out/FlutterArbFileExporter.kt | 109 + .../flutter/out/FlutterArbFileWriter.kt | 42 + .../out/FlutterArbFromIcuParamConvertor.kt | 18 + .../out/IcuToFlutterArbMessageConvertor.kt | 16 + .../IcuToGenericFormatMessageConvertor.kt | 43 + .../main/kotlin/io/tolgee/formats/icuUtil.kt | 10 + .../formats/json/in/JsonFileProcessor.kt | 80 + .../formats/json/out/JsonFileExporter.kt | 42 + .../kotlin/io/tolgee/formats/localeUtil.kt | 26 + .../StructureModelBuilder.kt | 286 ++ .../nestedStructureModel.kt | 28 + .../io/tolgee/formats/paramConversionUtil.kt | 48 + .../io/tolgee/formats/path/PathParser.kt | 182 + .../io/tolgee/formats/path/pathItemUtil.kt | 39 + .../data => formats/pluralData}/PluralData.kt | 2 +- .../pluralData}/PluralExample.kt | 2 +- .../pluralData}/PluralLanguage.kt | 2 +- .../tolgee/formats/pluralFormExamplesUtil.kt | 65 + .../io/tolgee/formats/pluralFormsUtil.kt | 357 ++ .../formats/po/PoSupportedMessageFormat.kt | 66 + .../kotlin/io/tolgee/formats/po/contsants.kt | 3 + .../formats/po/in/CLikeParameterParser.kt | 30 + .../po/in}/FormatDetector.kt | 16 +- .../tolgee/formats/po/in/ParsedCLikeParam.kt | 12 + .../po => formats/po/in}/PoFileProcessor.kt | 69 +- .../po => formats/po/in}/PoParser.kt | 8 +- .../po/in}/data/PoParsedTranslation.kt | 2 +- .../po => formats/po/in}/data/PoParserMeta.kt | 2 +- .../po/in}/data/PoParserResult.kt | 2 +- .../po/in}/data/PoTranslationMeta.kt | 2 +- .../BasePoToIcuMessageConvertor.kt | 70 + .../PoCToIcuImportMessageConvertor.kt | 21 + .../PoPhpToIcuImportMessageConvertor.kt | 21 + .../PoPythonToIcuImportMessageConvertor.kt | 21 + .../paramConvertors/CToIcuParamConvertor.kt | 56 + .../paramConvertors/PhpToIcuParamConvertor.kt | 57 + .../PythonToIcuParamConvertor.kt | 55 + .../po/out/BaseIcuMessageToPoConvertor.kt | 109 + .../tolgee/formats/po/out/PoFileExporter.kt | 107 + .../formats/po/out/ToPoConversionResult.kt | 16 + .../formats/po/out/ToPoMessageConvertor.kt | 5 + .../po/out/c/CFromIcuParamConvertor.kt | 57 + .../formats/po/out/c/ToCPoMessageConvertor.kt | 25 + .../po/out/messageToMultilineConvertor.kt | 29 + .../po/out/php/PhpFromIcuParamConvertor.kt | 71 + .../po/out/php/ToPhpPoMessageConvertor.kt | 25 + .../out/python/PythonFromIcuParamConvertor.kt | 62 + .../out/python/ToPythonPoMessageConvertor.kt | 25 + .../properties/in/PropertiesFileProcessor.kt | 30 + .../properties/out/PropertiesFileExporter.kt | 93 + .../formats/replaceMatchedAndNotMatched.kt | 26 + .../formats/xliff/in/Xliff12FileProcessor.kt | 53 + .../formats/xliff/in/XliffFileProcessor.kt | 46 + .../formats/xliff/in/parser/XliffParser.kt | 169 + .../tolgee/formats/xliff/model/XliffFile.kt | 28 + .../tolgee/formats/xliff/model/XliffModel.kt | 6 + .../formats/xliff/model/XliffTransUnit.kt | 9 + .../formats/xliff/out/XliffFileExporter.kt | 75 + .../formats/xliff/out/XliffFileWriter.kt | 85 + .../main/kotlin/io/tolgee/model/Project.kt | 13 +- .../contentDelivery/ContentDeliveryConfig.kt | 12 +- .../io/tolgee/model/dataImport/ImportFile.kt | 35 +- .../io/tolgee/model/dataImport/ImportKey.kt | 2 + .../tolgee/model/dataImport/ImportSettings.kt | 29 + .../model/dataImport/ImportSettingsId.kt | 8 + .../model/dataImport/ImportTranslation.kt | 27 + .../issues/issueTypes/FileIssueType.kt | 2 + .../issues/paramTypes/FileIssueParamType.kt | 1 + .../model/enums/announcement/Announcement.kt | 3 +- .../main/kotlin/io/tolgee/model/key/Key.kt | 8 + .../kotlin/io/tolgee/model/key/KeyMeta.kt | 19 + .../tolgee/model/translation/Translation.kt | 4 + .../model/views/ImportTranslationView.kt | 5 + .../model/views/KeyWithTranslationsView.kt | 4 + .../io/tolgee/model/views/ProjectView.kt | 1 + .../model/views/ProjectWithLanguagesView.kt | 2 + .../model/views/ProjectWithStatsView.kt | 1 + .../io/tolgee/repository/KeyRepository.kt | 21 +- .../io/tolgee/repository/ProjectRepository.kt | 2 +- .../repository/TranslationRepository.kt | 17 +- .../dataImport/ImportLanguageRepository.kt | 3 +- .../dataImport/ImportTranslationRepository.kt | 36 +- .../io/tolgee/service/LanguageService.kt | 8 +- .../io/tolgee/service/StartupImportService.kt | 4 +- .../dataImport/CoreImportFilesProcessor.kt | 126 +- .../service/dataImport/ImportDataManager.kt | 234 +- .../service/dataImport/ImportService.kt | 102 +- .../dataImport/ImportSettingsService.kt | 60 + .../dataImport/PluralizationHandler.kt | 84 + .../service/dataImport/StoredDataImporter.kt | 36 +- .../processors/FileProcessorContext.kt | 143 +- .../processors/JsonFileProcessor.kt | 49 - .../dataImport/processors/ProcessorFactory.kt | 36 - .../processors/PropertyFileProcessor.kt | 15 - .../messageFormat/SupportedFormat.kt | 12 - .../messageFormat/ToICUConverter.kt | 137 - .../processors/xliff/Xliff12FileProcessor.kt | 140 - .../processors/xliff/XliffFileProcessor.kt | 44 - .../status/ImportApplicationStatus.kt | 1 + .../status/ImportApplicationStatusItem.kt | 4 + .../io/tolgee/service/export/ExportService.kt | 2 + .../service/export/FileExporterFactory.kt | 97 +- .../export/dataProvider/ExportDataProvider.kt | 13 +- .../export/dataProvider/ExportDataView.kt | 3 + .../export/dataProvider/ExportKeyView.kt | 9 +- .../dataProvider/ExportTranslationView.kt | 4 +- .../service/export/exporters/FileExporter.kt | 11 +- .../export/exporters/JsonFileExporter.kt | 89 - .../export/exporters/XliffFileExporter.kt | 119 - .../io/tolgee/service/key/KeyMetaService.kt | 12 + .../io/tolgee/service/key/KeyService.kt | 37 +- .../service/key/ResolvingKeyImporter.kt | 131 +- .../tolgee/service/key/utils/KeysImporter.kt | 8 +- .../service/machineTranslation/KeyForMt.kt | 3 +- .../machineTranslation/MetadataProvider.kt | 13 +- .../machineTranslation/MtBatchTranslator.kt | 143 +- .../machineTranslation/MtTranslator.kt | 2 +- .../machineTranslation/MtTranslatorContext.kt | 95 +- .../PluralTranslationUtil.kt | 130 + .../tolgee/service/project/ProjectService.kt | 12 +- .../translationViewBuilder/QueryBase.kt | 4 + .../translation/TranslationMemoryService.kt | 2 + .../service/translation/TranslationService.kt | 95 +- .../main/kotlin/io/tolgee/util/WordCounter.kt | 19 +- .../main/kotlin/io/tolgee/util/domBuilder.kt | 82 + .../io/tolgee/util/filterImportFiles.kt | 17 + .../main/kotlin/io/tolgee/util/nullIfEmpty.kt | 5 + .../main/kotlin/io/tolgee/util/stringUtil.kt | 4 + .../main/resources/db/changelog/schema.xml | 103 + .../MtBatchTranslatorTest.kt | 238 + .../unit/CoreImportFileProcessorUnitTest.kt | 39 +- .../formatConversions/CPoConversionTest.kt | 36 + .../formatConversions/PhpPoConversionTest.kt | 35 + .../PythonPoConversionTest.kt | 36 + .../BaseIcuImportMessageConvertorTest.kt | 43 + .../io/tolgee/unit/formats/LocaleUtilTest.kt | 15 + .../unit/formats/MessagePatternUtilTest.kt | 245 + .../io/tolgee/unit/formats/PathParserTest.kt | 95 + .../formats/PluralFormExamplesUtilTest.kt | 23 + .../unit/formats/PluralsFormUtilTest.kt | 249 + .../unit/formats/StructureModelBuilderTest.kt | 241 + .../in/AndroidXmlFormatProcessorTest.kt | 188 + .../android/out/AdnroidXmlFileExporterTest.kt | 258 + .../apple/in/AppleXliffFormatProcessorTest.kt | 363 ++ .../apple/in/StringsFormatProcessorTest.kt | 114 + .../in/StringsdictFormatProcessorTest.kt | 121 + .../apple/out/AppleXliffFileExporterTest.kt | 415 ++ .../IcuToAppleImportMessageConvertorTest.kt | 51 + .../out/StringsStringsdictFileExporterTest.kt | 312 ++ .../formats/escaping/ForceIcuEscaperTest.kt | 58 + .../unit/formats/escaping/IcuUnescaperTest.kt | 64 + .../escaping/PluralFormIcuEscaperTest.kt | 50 + .../formats/escaping/TwoWayEscapingTest.kt | 44 + .../in/FlutterArbFormatProcessorTest.kt | 168 + .../out/FlutterArbFileExporterTest.kt | 210 + .../json/in/JsonFormatProcessorTest.kt | 170 + .../formats/json/out}/JsonFileExporterTest.kt | 94 +- .../unit/formats/po/in/FormatDetectorTest.kt | 30 + .../unit/formats/po/in/PoFileProcessorTest.kt | 163 + .../tolgee/unit/formats/po/in/PoParserTest.kt | 29 + .../formats/po/in/PoToICUConverterTest.kt | 106 + .../po/out/BaseIcuMessageToPoConvertorTest.kt | 76 + .../unit/formats/po/out/PoFileExporterTest.kt | 352 ++ .../po/out/PoMessageFormatsExporterTest.kt | 104 + .../in/PropertiesFileProcessorTest.kt | 169 + .../properties/out/JsonFileExporterTest.kt | 162 + .../xliff/in/Xliff12FileProcessorTest.kt | 206 + .../xliff/in/XliffFileProcessorTest.kt | 24 + .../xliff/out}/XliffFileExporterTest.kt | 162 +- .../processors/PropertiesParserTest.kt | 43 - .../messageFormat/FormatDetectorTest.kt | 45 - .../messageFormat/ToICUConverterTest.kt | 111 - .../processors/po/PoFileProcessorTest.kt | 90 - .../processors/processors/po/PoParserTest.kt | 44 - .../xliff/Xliff12FileProcessorTest.kt | 110 - .../xliff/XliffFileProcessorTest.kt | 39 - .../io/tolgee/unit/util/exportAssertUtil.kt | 17 + .../io/tolgee/unit/util/testGenerationUtil.kt | 106 + .../util/FileProcessorContextMockUtil.kt | 54 + .../kotlin/io/tolgee/util/WordCounterTest.kt | 9 - .../kotlin/io/tolgee/util/exportTestUtil.kt | 66 + .../kotlin/io/tolgee/util/importTestUtil.kt | 140 + .../test/resources/import/android/strings.xml | 26 + .../android/strings_params_everywhere.xml | 13 + .../import/apple/Localizable.strings | 24 + .../import/apple/Localizable_params.strings | 1 + .../apple/Localizable_params.stringsdict | 22 + .../src/test/resources/import/apple/cs.xliff | 106 + .../resources/import/apple/en_xcstrings.xliff | 37 + .../import/apple/example.stringsdict | 40 + .../import/apple/params_everywhere_cs.xliff | 48 + .../test/resources/import/example.properties | 7 - .../test/resources/import/flutter/app_en.arb | 20 + .../import/flutter/app_en_params.arb | 5 + .../test/resources/import/json/example.json | 24 + .../resources/import/json/example_params.json | 4 + .../import/json/example_root_array.json | 4 + .../test/resources/import/json/plural.json | 3 + .../resources/import/po/example_params.po | 17 + .../import/po/example_raw_escaping.po | 17 + .../import/properties/example.properties | 12 + .../properties/example_params.properties | 2 + .../import/xliff/example_params.xliff | 13 + .../import/xliff/preserving-spaces.xliff | 24 + .../xliff/xliff-core-1.2-transitional.xsd | 0 .../main/kotlin/io/tolgee/testing/assert.kt | 4 + e2e/cypress/common/apiCalls/common.ts | 17 +- .../common/apiCalls/testData/testData.ts | 3 +- e2e/cypress/common/comments.ts | 15 +- e2e/cypress/common/export.ts | 145 +- .../common/permissions/translations.ts | 6 +- e2e/cypress/common/state.ts | 4 +- e2e/cypress/common/translations.ts | 46 +- .../e2e/{userSettings => }/announcement.cy.ts | 4 +- e2e/cypress/e2e/formerUser.cy.ts | 3 +- e2e/cypress/e2e/import/importErrors.cy.ts | 15 +- e2e/cypress/e2e/import/importResolving.cy.ts | 36 +- e2e/cypress/e2e/import/importSettings.cy.ts | 122 + .../e2e/projects/contentDelivery.cy.ts | 91 +- e2e/cypress/e2e/projects/contentStorage.cy.ts | 8 +- e2e/cypress/e2e/projects/export.cy.ts | 60 - e2e/cypress/e2e/projects/export/export.cy.ts | 98 + .../e2e/projects/export/exportFormats.cy.ts | 52 + .../e2e/projects/placeholdersDisabled.cy.ts | 32 + .../e2e/projects/projectDashboard.cy.ts | 2 +- e2e/cypress/e2e/projects/projectMembers.cy.ts | 5 + e2e/cypress/e2e/projects/transferring.cy.ts | 3 +- e2e/cypress/e2e/projects/webhooks.cy.ts | 6 +- .../e2e/security/sensitiveOperations.cy.ts | 2 +- e2e/cypress/e2e/translations/base.cy.ts | 34 + e2e/cypress/e2e/translations/batchJobs.cy.ts | 12 +- e2e/cypress/e2e/translations/comments.cy.ts | 83 +- e2e/cypress/e2e/translations/outdated.cy.ts | 10 +- .../translations/placeholdersDisabled.cy.ts | 69 + .../translations/pluralTranslationTools.cy.ts | 130 + e2e/cypress/e2e/translations/plurals.cy.ts | 59 + .../e2e/translations/singleKeyForm.cy.ts | 12 +- .../e2e/translations/translationMemory.cy.ts | 4 - .../with5translations/shortcuts.cy.ts | 16 - .../with5translations/withViews.cy.ts | 2 +- e2e/cypress/fixtures/import/error.jsn | 1 - e2e/cypress/fixtures/import/error.json.zip | Bin 0 -> 493 bytes .../fixtures/import/po/placeholders.po | 12 + e2e/cypress/support/dataCyType.d.ts | 24 +- .../CloudTolgeeTranslateApiServiceImpl.kt | 5 + gradle/docker.gradle | 22 + gradle/webapp.gradle | 3 +- pluralTest.json | 5 + settings.gradle | 2 +- webapp/jest.config.js | 5 + webapp/package-lock.json | 4572 +++++++++-------- webapp/package.json | 13 +- webapp/src/ThemeProvider.tsx | 9 + webapp/src/colors.tsx | 87 +- webapp/src/component/AutoTranslationIcon.tsx | 2 +- .../src/component/DangerZone/DangerButton.tsx | 2 +- .../src/component/DangerZone/DangerZone.tsx | 2 +- .../LimitedHeightText.tsx | 4 +- .../ListComponents.tsx} | 0 .../form/LoadingCheckboxWithSkeleton.tsx | 105 + .../common/form/PluralFormCheckbox.tsx | 83 + webapp/src/component/editor/Editor.tsx | 395 +- webapp/src/component/editor/EditorJson.tsx | 193 + webapp/src/component/editor/EditorWrapper.tsx | 12 +- webapp/src/component/editor/icuMode.ts | 215 - webapp/src/component/editor/icuParser.ts | 76 - webapp/src/component/editor/icuVariants.ts | 172 - webapp/src/component/layout/BaseView.tsx | 3 +- .../QuickStartGuide/QuickStartGuide.tsx | 2 +- .../layout/TopBanner/useAnnouncement.tsx | 10 + webapp/src/component/reactList/ReactList.d.ts | 35 + webapp/src/component/reactList/ReactList.js | 554 ++ .../searchSelect/SearchSelectMulti.tsx | 2 +- .../security/LanguagePermissionsMenu.tsx | 2 +- .../src/constants/GlobalValidationSchema.tsx | 28 + webapp/src/constants/links.tsx | 1 + webapp/src/custom.d.ts | 11 + webapp/src/docLinks.ts | 6 + webapp/src/fixtures/FileUploadFixtures.ts | 103 + webapp/src/fixtures/isElementInput.ts | 12 + .../translations => fixtures}/useTimer.ts | 0 webapp/src/fixtures/validateObject.ts | 13 + webapp/src/globalContext/GlobalContext.tsx | 7 +- .../src/globalContext/useQuickStartGuide.tsx | 5 +- webapp/src/i18n/cs.json | 23 +- webapp/src/i18n/da.json | 24 +- webapp/src/i18n/de.json | 74 +- webapp/src/i18n/en.json | 23 +- webapp/src/i18n/es.json | 2 + webapp/src/i18n/fr.json | 5 +- webapp/src/i18n/nl.json | 218 +- webapp/src/i18n/no.json | 357 +- webapp/src/i18n/pt.json | 2 + webapp/src/i18n/ro.json | 24 +- webapp/src/i18n/ru.json | 1 + webapp/src/i18n/zh.json | 61 +- webapp/src/service/TranslationHooks.ts | 1 + webapp/src/service/apiSchema.generated.ts | 401 +- webapp/src/svgs/logos/android.svg | 10 + webapp/src/svgs/logos/apple.svg | 3 + webapp/src/svgs/logos/c.svg | 3 + webapp/src/svgs/logos/flutter.svg | 3 + webapp/src/svgs/logos/icu.svg | 5 + webapp/src/svgs/logos/php.svg | 3 + webapp/src/svgs/logos/python.svg | 16 + webapp/src/svgs/tolgeeLogo.svg | 1 + webapp/src/tokens.ts | 374 ++ .../translationTools/useErrorTranslation.ts | 2 + .../useFileIssueParamTranslation.ts | 6 + .../useFileIssueTranslation.ts | 4 + .../eeLicense/EeLicenseHint.tsx | 9 +- webapp/src/views/projects/ProjectRouter.tsx | 2 +- .../contentDelivery/CdEditDialog.tsx | 190 +- .../src/views/projects/export/ExportForm.tsx | 65 +- .../export/components/FormatSelector.tsx | 76 +- .../components/SupportArraysSelector.tsx | 52 + .../export/components/formatGroups.tsx | 171 + .../src/views/projects/import/ImportView.tsx | 5 +- .../component/ImportConflictTranslation.tsx | 10 +- .../ImportConflictTranslationsPair.tsx | 12 +- .../import/component/ImportConflictsData.tsx | 2 +- .../import/component/ImportFileDropzone.tsx | 20 +- .../import/component/ImportFileInput.tsx | 44 +- .../import/component/ImportSettingsPanel.tsx | 150 + .../component/ImportSupportedFormats.tsx | 128 + .../component/ImportTranslationsDialog.tsx | 59 +- .../import/component/LanguageSelector.tsx | 20 +- .../import/hooks/useApplyImportHelper.tsx | 9 +- .../import/hooks/useImportDataHelper.tsx | 7 +- .../MachineTranslation/ServiceAvatar.tsx | 2 +- .../MachineTranslation/ServiceLabel.tsx | 2 +- .../projects/project/ProjectCreateView.tsx | 32 +- .../project/ProjectSettingsAdvanced.tsx | 126 + .../project/ProjectSettingsGeneral.tsx | 146 + .../projects/project/ProjectSettingsView.tsx | 222 +- .../project/components/BaseLanguageSelect.tsx | 42 +- .../views/projects/translations/CellKey.tsx | 38 +- .../projects/translations/ColumnResizer.tsx | 12 +- .../translations/Filters/FiltersMenu.tsx | 4 +- .../translations/Filters/SubmenuMulti.tsx | 2 +- .../translations/Filters/SubmenuStates.tsx | 4 +- .../Filters/useFiltersContent.tsx | 6 +- .../translations/KeyCreateForm/FormBody.tsx | 164 +- .../KeyCreateForm/KeyCreateForm.tsx | 57 +- .../translations/KeyEdit/KeyCustomValues.tsx | 31 + .../translations/KeyEdit/KeyEditModal.tsx | 64 +- .../translations/KeyEdit/KeyGeneral.tsx | 24 +- .../projects/translations/KeyEdit/types.ts | 3 + .../translations/KeySingle/KeyEditForm.tsx | 229 +- .../translations/KeySingle/KeySingle.tsx | 22 +- .../ToolsPanel/FloatingToolsPanel.tsx | 79 + .../translations/ToolsPanel/ToolsPanel.tsx | 139 + .../translations/ToolsPanel/common/Panel.tsx | 114 + .../ToolsPanel/common/SmallActionButton.tsx | 33 + .../common/StickyDateSeparator.tsx | 32 +- .../ToolsPanel/common/StyledLoadMore.tsx | 18 + .../common}/TabMessage.tsx | 3 +- .../ToolsPanel/common/splitByParameter.ts | 13 + .../translations/ToolsPanel/common/types.ts | 30 + .../ToolsPanel/common/useExtractedPlural.tsx | 45 + .../panels/Comments}/Comment.tsx | 7 +- .../ToolsPanel/panels/Comments/Comments.tsx | 179 + .../panels/Comments}/useComments.tsx | 30 +- .../ToolsPanel/panels/History/History.tsx | 119 + .../panels/History}/HistoryItem.tsx | 6 +- .../panels/History/HistoryTypes.ts} | 0 .../panels/History}/mapHistoryToActivity.ts | 2 +- .../ToolsPanel/panels/History/useHistory.tsx | 65 + .../KeyboardShortcuts/KeyboardShortcuts.tsx | 81 + .../panels/KeyboardShortcuts/Shortcut.tsx | 29 + .../MachineTranslation/MachineTranslation.tsx | 125 + .../MachineTranslationItem.tsx | 131 + .../MachineTranslation}/ProviderLogo.tsx | 0 .../MachineTranslation}/useMTStreamed.tsx | 0 .../MachineTranslation}/useServiceImg.ts | 0 .../TranslationMemory/TranslationMemory.tsx | 77 + .../TranslationMemoryItem.tsx | 178 + .../translations/ToolsPanel/panelsList.tsx | 51 + .../translations/ToolsPanel/useOpenPanels.ts | 31 + .../translations/TranslationEditor.tsx | 48 + .../TranslationHeader/KeyCreateDialog.tsx | 59 +- .../TranslationHeader/StickyHeader.tsx | 9 +- .../translations/TranslationOpened.tsx | 267 - .../TranslationTools/MachineTranslation.tsx | 151 - .../TranslationTools/PopupArrow.tsx | 34 - .../TranslationTools/ToolsBottomPanel.tsx | 22 - .../TranslationTools/ToolsPopup.tsx | 70 - .../TranslationTools/ToolsTab.tsx | 81 - .../TranslationTools/TranslationMemory.tsx | 122 - .../TranslationTools/TranslationTools.tsx | 110 - .../TranslationTools/useTranslationTools.ts | 104 - .../translations/TranslationVisual.tsx | 145 - .../projects/translations/Translations.tsx | 60 +- .../TranslationsList/CellTranslation.tsx | 197 +- .../translations/TranslationsList/RowList.tsx | 19 +- .../TranslationsList/TranslationLanguage.tsx | 53 + .../TranslationsList/TranslationRead.tsx | 123 + .../TranslationsList/TranslationWrite.tsx | 195 + .../TranslationsList/TranslationsList.tsx | 45 +- .../translations/TranslationsShortcuts.tsx | 308 -- .../TranslationsTable/CellLanguage.tsx | 8 +- .../TranslationsTable/CellTranslation.tsx | 160 +- .../TranslationsTable/RowTable.tsx | 41 +- .../TranslationsTable/TranslationRead.tsx | 116 + .../TranslationsTable/TranslationWrite.tsx | 157 + .../TranslationsTable/TranslationsTable.tsx | 368 +- .../TranslationsTable/useScrollStatus.ts | 42 + .../translations/TranslationsToolbar.tsx | 22 +- .../translations/TranslationsView.tsx | 5 +- .../translations/cell/CellStateBar.tsx | 1 + .../translations/cell/ControlsEditor.tsx | 144 - .../translations/cell/ControlsEditorMain.tsx | 51 + .../cell/ControlsEditorReadOnly.tsx | 33 + .../translations/cell/ControlsEditorSmall.tsx | 103 + .../translations/cell/ControlsTranslation.tsx | 16 +- .../translations/cell/MissingPlaceholders.tsx | 73 + .../translations/cell/TranslationFlags.tsx | 2 +- .../projects/translations/cell/styles.ts | 4 + .../cell/useMissingPlaceholders.ts | 51 + .../translations/comments/Comments.tsx | 189 - .../translations/context/ColumnsContext.ts | 62 - .../translations/context/HeaderNsContext.ts | 20 +- .../context/TranslationsContext.ts | 30 +- .../context/services/useEditService.tsx | 210 +- .../services/useTranslationsService.tsx | 59 +- .../context/services/useWebsocketService.ts | 5 +- .../shortcuts/useTranslationsShortcuts.ts | 8 +- .../projects/translations/context/types.ts | 17 +- .../projects/translations/history/History.tsx | 178 - .../translationVisual/PluralEditor.tsx | 73 + .../translationVisual/TranslationPlurals.tsx | 139 + .../translationVisual/TranslationVisual.tsx | 67 + .../TranslationWithPlaceholders.tsx | 68 + .../placeholderToElement.tsx | 44 + .../useTranslationPlurals.tsx | 0 .../translations/useBaseTranslation.ts | 23 + .../views/projects/translations/useColumns.ts | 83 + .../projects/translations/useEditableRow.ts | 129 - .../views/projects/translations/useKeyCell.ts | 71 + .../views/projects/translations/useResize.ts | 74 +- .../translations/useTranslationCell.ts | 175 + 597 files changed, 29052 insertions(+), 9021 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt rename backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/{ => dataImport}/V2ImportController.kt (91%) create mode 100644 backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportSettingsModel.kt rename backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/{TranslationSuggestionControllerTest.kt => TranslationSuggestionControllerMtTest.kt} (88%) create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTmTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerConflictsBetweenFilesTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerPluralizationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerApplicationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerTest.kt rename backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/{KeyControllerComplexUpdateTest.kt => KeyControllerComplexEditTest.kt} (85%) create mode 100644 backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerPluralizationTest.kt create mode 100644 backend/app/src/test/kotlin/io/tolgee/util/performImport.kt create mode 100644 backend/app/src/test/resources/import/almost_simple.json create mode 100644 backend/app/src/test/resources/import/android/strings_params_everywhere.xml create mode 100644 backend/app/src/test/resources/import/apple/params_everywhere_cs.xliff create mode 100644 backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.strings create mode 100644 backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.stringsdict create mode 100644 backend/data/src/main/kotlin/io/tolgee/api/IImportSettings.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/api/ISimpleProject.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/component/KeyCustomValuesValidator.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportPluralizationTestData.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/dataImport/ImportSettingsRequest.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportFormat.kt rename backend/data/src/main/kotlin/io/tolgee/dtos/request/project/{CreateProjectDTO.kt => CreateProjectRequest.kt} (87%) rename backend/data/src/main/kotlin/io/tolgee/dtos/request/project/{EditProjectDTO.kt => EditProjectRequest.kt} (69%) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/BaseIcuMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/CollisionHandler.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ExportMessageFormat.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/FormsToIcuPluralConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/FromIcuParamConvertor.kt rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors => formats}/ImportFileProcessor.kt (83%) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertorType.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorFactory.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorResult.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/MessagePatternUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/NoOpFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/PossiblePluralConversionResult.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/StringWrapper.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/ToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/androidStringsXmlModel.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlParser.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidToIcuMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/in/JavaToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlFileWriter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/out/IcuToJavaMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/android/out/JavaFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/appleFormatConstants.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleCollisionHandler.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/langGuessUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/namespaceGuessUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/stringdict/StringsdictFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/strings/StringsFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/in/xliff/AppleXliffFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleStringsStringsdictExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffTransUnitInfo.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/IcuToAppleMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsWriter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsdictWriter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/constants.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/escaping/ForceIcuEscaper.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/escaping/IcuUnescper.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/escaping/PluralFormIcuEscaper.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/contsants.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/flutterArbModel.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParseException.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParser.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileWriter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/IcuToFlutterArbMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/generic/IcuToGenericFormatMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/icuUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/json/in/JsonFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/json/out/JsonFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/localeUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/StructureModelBuilder.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/nestedStructureModel.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/paramConversionUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/path/PathParser.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/path/pathItemUtil.kt rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/messageFormat/data => formats/pluralData}/PluralData.kt (99%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/messageFormat/data => formats/pluralData}/PluralExample.kt (50%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/messageFormat/data => formats/pluralData}/PluralLanguage.kt (73%) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/pluralFormExamplesUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/pluralFormsUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/PoSupportedMessageFormat.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/CLikeParameterParser.kt rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/messageFormat => formats/po/in}/FormatDetector.kt (57%) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/ParsedCLikeParam.kt rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/PoFileProcessor.kt (53%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/PoParser.kt (96%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/data/PoParsedTranslation.kt (91%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/data/PoParserMeta.kt (76%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/data/PoParserResult.kt (64%) rename backend/data/src/main/kotlin/io/tolgee/{service/dataImport/processors/po => formats/po/in}/data/PoTranslationMeta.kt (90%) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/BasePoToIcuMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoCToIcuImportMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPhpToIcuImportMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPythonToIcuImportMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/CToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PhpToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PythonToIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/BaseIcuMessageToPoConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoConversionResult.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/CFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/ToCPoMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/messageToMultilineConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/PhpFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/ToPhpPoMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/PythonFromIcuParamConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/ToPythonPoMessageConvertor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/properties/in/PropertiesFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/properties/out/PropertiesFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/replaceMatchedAndNotMatched.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/Xliff12FileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/XliffFileProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/parser/XliffParser.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffFile.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffModel.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffTransUnit.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileWriter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettings.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettingsId.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportSettingsService.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/PluralizationHandler.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/JsonFileProcessor.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ProcessorFactory.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/PropertyFileProcessor.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/SupportedFormat.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/ToICUConverter.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/Xliff12FileProcessor.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/XliffFileProcessor.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/export/exporters/JsonFileExporter.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/export/exporters/XliffFileExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/PluralTranslationUtil.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/util/domBuilder.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/util/filterImportFiles.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/util/nullIfEmpty.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/CPoConversionTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PhpPoConversionTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PythonPoConversionTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/BaseIcuImportMessageConvertorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/LocaleUtilTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/MessagePatternUtilTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/PathParserTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/PluralFormExamplesUtilTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/PluralsFormUtilTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/StructureModelBuilderTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/android/in/AndroidXmlFormatProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/android/out/AdnroidXmlFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/AppleXliffFormatProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsFormatProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsdictFormatProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/IcuToAppleImportMessageConvertorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/StringsStringsdictFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/ForceIcuEscaperTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/IcuUnescaperTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/PluralFormIcuEscaperTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/TwoWayEscapingTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/in/FlutterArbFormatProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/out/FlutterArbFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonFormatProcessorTest.kt rename backend/{app/src/test/kotlin/io/tolgee/service/export/exporters => data/src/test/kotlin/io/tolgee/unit/formats/json/out}/JsonFileExporterTest.kt (58%) create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/FormatDetectorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoToICUConverterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/BaseIcuMessageToPoConvertorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoMessageFormatsExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/in/PropertiesFileProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/out/JsonFileExporterTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/Xliff12FileProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/XliffFileProcessorTest.kt rename backend/{app/src/test/kotlin/io/tolgee/service/export/exporters => data/src/test/kotlin/io/tolgee/unit/formats/xliff/out}/XliffFileExporterTest.kt (57%) delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/PropertiesParserTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/FormatDetectorTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/ToICUConverterTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoFileProcessorTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoParserTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/Xliff12FileProcessorTest.kt delete mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/XliffFileProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/util/exportAssertUtil.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/util/FileProcessorContextMockUtil.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/util/exportTestUtil.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/util/importTestUtil.kt create mode 100644 backend/data/src/test/resources/import/android/strings.xml create mode 100644 backend/data/src/test/resources/import/android/strings_params_everywhere.xml create mode 100644 backend/data/src/test/resources/import/apple/Localizable.strings create mode 100644 backend/data/src/test/resources/import/apple/Localizable_params.strings create mode 100644 backend/data/src/test/resources/import/apple/Localizable_params.stringsdict create mode 100644 backend/data/src/test/resources/import/apple/cs.xliff create mode 100644 backend/data/src/test/resources/import/apple/en_xcstrings.xliff create mode 100644 backend/data/src/test/resources/import/apple/example.stringsdict create mode 100644 backend/data/src/test/resources/import/apple/params_everywhere_cs.xliff delete mode 100644 backend/data/src/test/resources/import/example.properties create mode 100644 backend/data/src/test/resources/import/flutter/app_en.arb create mode 100644 backend/data/src/test/resources/import/flutter/app_en_params.arb create mode 100644 backend/data/src/test/resources/import/json/example.json create mode 100644 backend/data/src/test/resources/import/json/example_params.json create mode 100644 backend/data/src/test/resources/import/json/example_root_array.json create mode 100644 backend/data/src/test/resources/import/json/plural.json create mode 100644 backend/data/src/test/resources/import/po/example_params.po create mode 100644 backend/data/src/test/resources/import/po/example_raw_escaping.po create mode 100644 backend/data/src/test/resources/import/properties/example.properties create mode 100644 backend/data/src/test/resources/import/properties/example_params.properties create mode 100644 backend/data/src/test/resources/import/xliff/example_params.xliff create mode 100644 backend/data/src/test/resources/import/xliff/preserving-spaces.xliff rename backend/{app/src/test/resources => data/src/test/resources/import}/xliff/xliff-core-1.2-transitional.xsd (100%) rename e2e/cypress/e2e/{userSettings => }/announcement.cy.ts (93%) create mode 100644 e2e/cypress/e2e/import/importSettings.cy.ts delete mode 100644 e2e/cypress/e2e/projects/export.cy.ts create mode 100644 e2e/cypress/e2e/projects/export/export.cy.ts create mode 100644 e2e/cypress/e2e/projects/export/exportFormats.cy.ts create mode 100644 e2e/cypress/e2e/projects/placeholdersDisabled.cy.ts create mode 100644 e2e/cypress/e2e/translations/placeholdersDisabled.cy.ts create mode 100644 e2e/cypress/e2e/translations/pluralTranslationTools.cy.ts create mode 100644 e2e/cypress/e2e/translations/plurals.cy.ts delete mode 100644 e2e/cypress/fixtures/import/error.jsn create mode 100644 e2e/cypress/fixtures/import/error.json.zip create mode 100644 e2e/cypress/fixtures/import/po/placeholders.po create mode 100644 pluralTest.json create mode 100644 webapp/jest.config.js rename webapp/src/{views/projects/translations => component}/LimitedHeightText.tsx (98%) rename webapp/src/{views/projects/translations/Filters/FiltersComponents.tsx => component/ListComponents.tsx} (100%) create mode 100644 webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx create mode 100644 webapp/src/component/common/form/PluralFormCheckbox.tsx create mode 100644 webapp/src/component/editor/EditorJson.tsx delete mode 100644 webapp/src/component/editor/icuMode.ts delete mode 100644 webapp/src/component/editor/icuParser.ts delete mode 100644 webapp/src/component/editor/icuVariants.ts create mode 100644 webapp/src/component/reactList/ReactList.d.ts create mode 100644 webapp/src/component/reactList/ReactList.js create mode 100644 webapp/src/docLinks.ts create mode 100644 webapp/src/fixtures/isElementInput.ts rename webapp/src/{views/projects/translations => fixtures}/useTimer.ts (100%) create mode 100644 webapp/src/fixtures/validateObject.ts create mode 100644 webapp/src/svgs/logos/android.svg create mode 100644 webapp/src/svgs/logos/apple.svg create mode 100644 webapp/src/svgs/logos/c.svg create mode 100644 webapp/src/svgs/logos/flutter.svg create mode 100644 webapp/src/svgs/logos/icu.svg create mode 100644 webapp/src/svgs/logos/php.svg create mode 100644 webapp/src/svgs/logos/python.svg create mode 100644 webapp/src/tokens.ts create mode 100644 webapp/src/views/projects/export/components/SupportArraysSelector.tsx create mode 100644 webapp/src/views/projects/export/components/formatGroups.tsx create mode 100644 webapp/src/views/projects/import/component/ImportSettingsPanel.tsx create mode 100644 webapp/src/views/projects/import/component/ImportSupportedFormats.tsx create mode 100644 webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx create mode 100644 webapp/src/views/projects/project/ProjectSettingsGeneral.tsx create mode 100644 webapp/src/views/projects/translations/KeyEdit/KeyCustomValues.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/ToolsPanel.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/SmallActionButton.tsx rename webapp/src/{component => views/projects/translations/ToolsPanel}/common/StickyDateSeparator.tsx (61%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/StyledLoadMore.tsx rename webapp/src/views/projects/translations/{TranslationTools => ToolsPanel/common}/TabMessage.tsx (80%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/splitByParameter.ts create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/types.ts create mode 100644 webapp/src/views/projects/translations/ToolsPanel/common/useExtractedPlural.tsx rename webapp/src/views/projects/translations/{comments => ToolsPanel/panels/Comments}/Comment.tsx (96%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comments.tsx rename webapp/src/views/projects/translations/{comments => ToolsPanel/panels/Comments}/useComments.tsx (88%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/History/History.tsx rename webapp/src/views/projects/translations/{history => ToolsPanel/panels/History}/HistoryItem.tsx (96%) rename webapp/src/views/projects/translations/{history/types.ts => ToolsPanel/panels/History/HistoryTypes.ts} (100%) rename webapp/src/views/projects/translations/{history => ToolsPanel/panels/History}/mapHistoryToActivity.ts (92%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/History/useHistory.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/KeyboardShortcuts.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/Shortcut.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslation.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslationItem.tsx rename webapp/src/views/projects/translations/{TranslationTools => ToolsPanel/panels/MachineTranslation}/ProviderLogo.tsx (100%) rename webapp/src/views/projects/translations/{TranslationTools => ToolsPanel/panels/MachineTranslation}/useMTStreamed.tsx (100%) rename webapp/src/views/projects/translations/{TranslationTools => ToolsPanel/panels/MachineTranslation}/useServiceImg.ts (100%) create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemory.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx create mode 100644 webapp/src/views/projects/translations/ToolsPanel/useOpenPanels.ts create mode 100644 webapp/src/views/projects/translations/TranslationEditor.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationOpened.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/MachineTranslation.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/PopupArrow.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/ToolsBottomPanel.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/ToolsPopup.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/ToolsTab.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/TranslationMemory.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/TranslationTools.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts delete mode 100644 webapp/src/views/projects/translations/TranslationVisual.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsList/TranslationLanguage.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx delete mode 100644 webapp/src/views/projects/translations/TranslationsShortcuts.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx create mode 100644 webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts delete mode 100644 webapp/src/views/projects/translations/cell/ControlsEditor.tsx create mode 100644 webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx create mode 100644 webapp/src/views/projects/translations/cell/ControlsEditorReadOnly.tsx create mode 100644 webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx create mode 100644 webapp/src/views/projects/translations/cell/MissingPlaceholders.tsx create mode 100644 webapp/src/views/projects/translations/cell/useMissingPlaceholders.ts delete mode 100644 webapp/src/views/projects/translations/comments/Comments.tsx delete mode 100644 webapp/src/views/projects/translations/context/ColumnsContext.ts delete mode 100644 webapp/src/views/projects/translations/history/History.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/PluralEditor.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/TranslationPlurals.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/placeholderToElement.tsx create mode 100644 webapp/src/views/projects/translations/translationVisual/useTranslationPlurals.tsx create mode 100644 webapp/src/views/projects/translations/useBaseTranslation.ts create mode 100644 webapp/src/views/projects/translations/useColumns.ts delete mode 100644 webapp/src/views/projects/translations/useEditableRow.ts create mode 100644 webapp/src/views/projects/translations/useKeyCell.ts create mode 100644 webapp/src/views/projects/translations/useTranslationCell.ts diff --git a/.editorconfig b/.editorconfig index 3d85dd6197..bd99600e96 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,6 @@ max_line_length = 120 [backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralData.kt] max_line_length = 500 + +[**/in/**/*.{kt, kts}] +ktlint_standard_package-name = disabled diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt index 4174bebc5e..93acff36b6 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ApiKeyController.kt @@ -19,6 +19,7 @@ import io.tolgee.hateoas.apiKey.ApiKeyPermissionsModel import io.tolgee.hateoas.apiKey.ApiKeyWithLanguagesModel import io.tolgee.hateoas.apiKey.RevealedApiKeyModel import io.tolgee.hateoas.apiKey.RevealedApiKeyModelAssembler +import io.tolgee.hateoas.project.SimpleProjectModelAssembler import io.tolgee.model.ApiKey import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType @@ -64,6 +65,7 @@ class ApiKeyController( @Suppress("SpringJavaInjectionPointsAutowiringInspection") private val pagedResourcesAssembler: PagedResourcesAssembler, private val permissionService: PermissionService, + private val simpleProjectModelAssembler: SimpleProjectModelAssembler, ) { @PostMapping(path = ["/api-keys"]) @Operation(summary = "Creates new API key with provided scopes") @@ -176,6 +178,7 @@ class ApiKeyController( viewLanguageIds = computed.viewLanguageIds.toNormalizedPermittedLanguageSet(), stateChangeLanguageIds = computed.stateChangeLanguageIds.toNormalizedPermittedLanguageSet(), scopes = scopes, + project = simpleProjectModelAssembler.toModel(projectService.get(projectIdNotNull)), ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/KeyController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/KeyController.kt index 1270bdbcd5..7c44834627 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/KeyController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/KeyController.kt @@ -135,7 +135,7 @@ class KeyController( val key = keyService.findOptional(id).orElseThrow { NotFoundException() } key.checkInProject() keyService.edit(id, dto) - val view = KeyView(key.id, key.name, key?.namespace?.name, key.keyMeta?.description) + val view = KeyView(key.id, key.name, key?.namespace?.name, key.keyMeta?.description, key.keyMeta?.custom) return keyModelAssembler.toModel(view) } @@ -166,6 +166,19 @@ class KeyController( return keyPagedResourcesAssembler.toModel(data, keyModelAssembler) } + @GetMapping(value = ["{id}"]) + @Transactional + @Operation(summary = "Returns single key") + @RequiresProjectPermissions([Scope.KEYS_VIEW]) + @AllowApiAccess + fun get( + @PathVariable + id: Long, + ): KeyModel { + val key = keyService.getView(projectHolder.project.id, id) + return keyModelAssembler.toModel(key) + } + @DeleteMapping(value = [""]) @Transactional @Operation(summary = "Deletes one or multiple keys by their IDs in request body") diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ProjectsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ProjectsController.kt index cd93424593..834a769eb2 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ProjectsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ProjectsController.kt @@ -10,8 +10,8 @@ import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType import io.tolgee.constants.Message import io.tolgee.dtos.request.AutoTranslationSettingsDto -import io.tolgee.dtos.request.project.CreateProjectDTO -import io.tolgee.dtos.request.project.EditProjectDTO +import io.tolgee.dtos.request.project.CreateProjectRequest +import io.tolgee.dtos.request.project.EditProjectRequest import io.tolgee.dtos.request.project.SetPermissionLanguageParams import io.tolgee.exceptions.BadRequestException import io.tolgee.facade.ProjectPermissionFacade @@ -223,7 +223,7 @@ class V2ProjectsController( @AllowApiAccess(tokenType = AuthTokenType.ONLY_PAT) fun createProject( @RequestBody @Valid - dto: CreateProjectDTO, + dto: CreateProjectRequest, ): ProjectModel { organizationRoleService.checkUserIsOwner(dto.organizationId) val project = projectService.createProject(dto) @@ -238,7 +238,7 @@ class V2ProjectsController( @AllowApiAccess fun editProject( @RequestBody @Valid - dto: EditProjectDTO, + dto: EditProjectRequest, ): ProjectModel { val project = projectService.editProject(projectHolder.project.id, dto) return projectModelAssembler.toModel(projectService.getView(project.id)) diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt index 0f3bdd8693..5772f09caf 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt @@ -45,7 +45,7 @@ class V2ExportController( ) { @GetMapping(value = [""]) @Operation(summary = "Exports data") - @RequiresProjectPermissions([ Scope.TRANSLATIONS_VIEW ]) + @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess fun export( @ParameterObject params: ExportParams, @@ -66,7 +66,7 @@ class V2ExportController( summary = """Exports data (post). Useful when providing params exceeding allowed query size. """, ) - @RequiresProjectPermissions([ Scope.TRANSLATIONS_VIEW ]) + @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess fun exportPost( @RequestBody params: ExportParams, @@ -95,12 +95,10 @@ class V2ExportController( params: ExportParams, exported: Map, ): ResponseEntity { - if (params.zip) { - return getZipResponseEntity(exported) - } else if (exported.entries.size == 1) { + if (exported.entries.size == 1 && !params.zip) { return exportSingleFile(exported, params) } - throw BadRequestException(message = Message.MULTIPLE_FILES_MUST_BE_ZIPPED) + return getZipResponseEntity(exported) } private fun checkExportNotEmpty(exported: Map) { diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt new file mode 100644 index 0000000000..058b3048ed --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/ImportSettingsController.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020. Tolgee + */ + +package io.tolgee.api.v2.controllers.dataImport + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.dtos.request.dataImport.ImportSettingsRequest +import io.tolgee.hateoas.dataImport.ImportSettingsModel +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.AllowApiAccess +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.dataImport.ImportSettingsService +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Suppress("MVCPathVariableInspection") +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/projects/{projectId:\\d+}/import-settings", "/v2/projects/import-settings"]) +@Tag( + name = "Import Settings", + description = + "These endpoints enable you to store default settings for import. " + + "These settings are only used in the UI of Tolgee platform. " + + "It's not the default for importing via API endpoints. " + + "The settings are stored per user and per project.", +) +class ImportSettingsController( + private val projectHolder: ProjectHolder, + private val importSettingsService: ImportSettingsService, + private val authenticationFacade: AuthenticationFacade, +) { + @GetMapping("") + @Operation(description = "Returns import settings for the authenticated user and the project.") + @AllowApiAccess + @UseDefaultPermissions + fun get(): ImportSettingsModel { + val projectId = projectHolder.project.id + val settings = importSettingsService.get(authenticationFacade.authenticatedUserEntity, projectId) + return ImportSettingsModel(settings) + } + + @PutMapping("") + @Operation(description = "Stores import settings for the authenticated user and the project.") + @AllowApiAccess + @UseDefaultPermissions + fun store( + @Valid @RequestBody dto: ImportSettingsRequest, + ): ImportSettingsModel { + val projectId = projectHolder.project.id + val settings = importSettingsService.store(authenticationFacade.authenticatedUserEntity, projectId, dto) + return ImportSettingsModel(settings) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ImportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt similarity index 91% rename from backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ImportController.kt rename to backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt index 3bffc32fdc..8e86d79cd3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2ImportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/V2ImportController.kt @@ -2,9 +2,9 @@ * Copyright (c) 2020. Tolgee */ -package io.tolgee.api.v2.controllers +package io.tolgee.api.v2.controllers.dataImport -import com.fasterxml.jackson.databind.ObjectMapper +import io.sentry.Sentry import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -14,6 +14,7 @@ import io.tolgee.dtos.dataImport.ImportAddFilesParams import io.tolgee.dtos.dataImport.ImportFileDto import io.tolgee.dtos.dataImport.SetFileNamespaceRequest import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.ErrorException import io.tolgee.exceptions.ErrorResponseBody import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.dataImport.ImportAddFilesResultModel @@ -27,7 +28,6 @@ import io.tolgee.hateoas.dataImport.ImportTranslationModelAssembler import io.tolgee.model.Language import io.tolgee.model.dataImport.ImportFile import io.tolgee.model.dataImport.ImportLanguage -import io.tolgee.model.dataImport.ImportTranslation import io.tolgee.model.enums.Scope import io.tolgee.model.views.ImportFileIssueView import io.tolgee.model.views.ImportLanguageView @@ -42,8 +42,10 @@ import io.tolgee.service.dataImport.ImportService import io.tolgee.service.dataImport.status.ImportApplicationStatus import io.tolgee.service.dataImport.status.ImportApplicationStatusItem import io.tolgee.service.key.NamespaceService +import io.tolgee.util.Logging import io.tolgee.util.StreamingResponseBodyProvider -import jakarta.servlet.http.HttpServletRequest +import io.tolgee.util.filterFiles +import io.tolgee.util.logger import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable @@ -92,8 +94,7 @@ class V2ImportController( private val namespaceService: NamespaceService, private val importFileIssueModelAssembler: ImportFileIssueModelAssembler, private val streamingResponseBodyProvider: StreamingResponseBodyProvider, - private val objectMapper: ObjectMapper, -) { +) : Logging { @PostMapping("", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @Operation(description = "Prepares provided files to import.", summary = "Add files") @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @@ -102,7 +103,11 @@ class V2ImportController( @RequestPart("files") files: Array, @ParameterObject params: ImportAddFilesParams, ): ImportAddFilesResultModel { - val fileDtos = files.map { ImportFileDto(it.originalFilename ?: "", it.inputStream.readAllBytes()) } + val filteredFiles = filterFiles(files.map { (it.originalFilename ?: "") to it }) + val fileDtos = + filteredFiles.map { + ImportFileDto(it.originalFilename ?: "", it.inputStream.readAllBytes()) + } val errors = importService.addFiles( files = fileDtos, @@ -153,8 +158,32 @@ class V2ImportController( val writeStatus = { status: ImportApplicationStatus -> write(ImportApplicationStatusItem(status)) } - - this.importService.import(projectId, authenticationFacade.authenticatedUser.id, forceMode, writeStatus) + try { + this.importService.import(projectId, authenticationFacade.authenticatedUser.id, forceMode, writeStatus) + } catch (e: Exception) { + if (e !is BadRequestException) { + Sentry.captureException(e) + logger.error("Unexpected error while importing", e) + } + when (e) { + is ErrorException -> + write( + ImportApplicationStatusItem( + ImportApplicationStatus.ERROR, + errorStatusCode = e.httpStatus.value(), + errorResponseBody = ErrorResponseBody(e.code, e.params), + ), + ) + + else -> + write( + ImportApplicationStatusItem( + ImportApplicationStatus.ERROR, + errorStatusCode = 500, + ), + ) + } + } } } @@ -212,7 +241,14 @@ class V2ImportController( pageable: Pageable, ): PagedModel { checkImportLanguageInProject(languageId) - val translations = importService.getTranslationsView(languageId, pageable, onlyConflicts, onlyUnresolved, search) + val translations = + importService.getTranslationsView( + languageId, + pageable, + onlyConflicts, + onlyUnresolved, + search, + ) return pagedTranslationsResourcesAssembler.toModel(translations, importTranslationModelAssembler) } @@ -299,7 +335,6 @@ class V2ImportController( fun selectNamespace( @PathVariable fileId: Long, @RequestBody req: SetFileNamespaceRequest, - request: HttpServletRequest, ) { val file = checkFileFromProject(fileId) this.importService.selectNamespace(file, req.namespace) @@ -409,8 +444,7 @@ class V2ImportController( override: Boolean, ) { checkImportLanguageInProject(languageId) - val translation = checkTranslationOfLanguage(translationId, languageId) - return importService.resolveTranslationConflict(translation, override) + return importService.resolveTranslationConflict(translationId, languageId, override) } private fun checkFileFromProject(fileId: Long): ImportFile { @@ -437,16 +471,4 @@ class V2ImportController( } return language } - - private fun checkTranslationOfLanguage( - translationId: Long, - languageId: Long, - ): ImportTranslation { - val translation = importService.findTranslation(translationId) ?: throw NotFoundException() - - if (translation.language.id != languageId) { - throw BadRequestException(io.tolgee.constants.Message.IMPORT_LANGUAGE_NOT_FROM_PROJECT) - } - return translation - } } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt index 87a114d0af..1e25476f0e 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/suggestion/TranslationSuggestionController.kt @@ -96,7 +96,14 @@ class TranslationSuggestionController( securityService.checkLanguageTranslatePermission(projectHolder.project.id, listOf(targetLanguage.id)) val data = - dto.baseText?.let { baseText -> translationMemoryService.suggest(baseText, targetLanguage, pageable) } + dto.baseText?.let { baseText -> + translationMemoryService.suggest( + baseText, + isPlural = dto.isPlural ?: false, + targetLanguage, + pageable, + ) + } ?: let { val keyId = dto.keyId ?: throw BadRequestException(Message.KEY_NOT_FOUND) val key = keyService.findOptional(keyId).orElseThrow { NotFoundException(Message.KEY_NOT_FOUND) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt new file mode 100644 index 0000000000..5383a4466a --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/CreateOrUpdateTranslationsFacade.kt @@ -0,0 +1,92 @@ +package io.tolgee.api.v2.controllers.translation + +import io.tolgee.activity.ActivityHolder +import io.tolgee.activity.data.ActivityType +import io.tolgee.dtos.request.translation.SetTranslationsWithKeyDto +import io.tolgee.formats.convertToPluralIfAnyIsPlural +import io.tolgee.hateoas.translations.SetTranslationsResponseModel +import io.tolgee.hateoas.translations.TranslationModelAssembler +import io.tolgee.model.enums.Scope +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation +import io.tolgee.security.ProjectHolder +import io.tolgee.service.key.KeyService +import io.tolgee.service.security.SecurityService +import io.tolgee.service.translation.TranslationService +import jakarta.validation.Valid +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.RequestBody + +@Service +class CreateOrUpdateTranslationsFacade( + private val keyService: KeyService, + private val securityService: SecurityService, + private val projectHolder: ProjectHolder, + private val activityHolder: ActivityHolder, + private val translationService: TranslationService, + private val translationModelAssembler: TranslationModelAssembler, +) { + @Transactional + fun createOrUpdateTranslations( + @RequestBody @Valid + dto: SetTranslationsWithKeyDto, + ): SetTranslationsResponseModel { + val key = keyService.find(projectHolder.projectEntity.id, dto.key, dto.namespace) ?: return create(dto) + return setTranslations(dto, key) + } + + private fun create(dto: SetTranslationsWithKeyDto): SetTranslationsResponseModel { + securityService.checkProjectPermission(projectHolder.project.id, Scope.KEYS_EDIT) + activityHolder.activity = ActivityType.CREATE_KEY + val key = keyService.create(projectHolder.projectEntity, dto.key, dto.namespace) + val convertedToPlurals = dto.translations.convertToPluralIfAnyIsPlural() + if (convertedToPlurals != null) { + key.isPlural = true + key.pluralArgName = convertedToPlurals.argName + keyService.save(key) + } + + val translations = + translationService + .setForKey(key, convertedToPlurals?.convertedStrings ?: dto.translations) + return getSetTranslationsResponse(key, translations) + } + + private fun getSetTranslationsResponse( + key: Key, + translations: Map, + ): SetTranslationsResponseModel { + return SetTranslationsResponseModel( + keyId = key.id, + keyName = key.name, + keyNamespace = key.namespace?.name, + keyIsPlural = key.isPlural, + translations = + translations.entries.associate { (languageTag, translation) -> + languageTag to translationModelAssembler.toModel(translation) + }, + ) + } + + fun setTranslations( + @RequestBody @Valid + dto: SetTranslationsWithKeyDto, + key: Key? = null, + ): SetTranslationsResponseModel { + val keyNotNull = key ?: keyService.get(projectHolder.project.id, dto.key, dto.namespace) + securityService.checkLanguageTranslatePermissionsByTag(dto.translations.keys, projectHolder.project.id) + + val modifiedTranslations = translationService.setForKey(keyNotNull, dto.translations) + + val translations = + dto.languagesToReturn + ?.let { languagesToReturn -> + translationService.findForKeyByLanguages(keyNotNull, languagesToReturn) + .associateBy { it.language.tag } + } + ?: modifiedTranslations + + return getSetTranslationsResponse(keyNotNull, translations) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt index 6f756dd7c6..31a9d9f587 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt @@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tags -import io.tolgee.activity.ActivityHolder import io.tolgee.activity.ActivityService import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType @@ -33,7 +32,6 @@ import io.tolgee.hateoas.translations.TranslationModelAssembler import io.tolgee.model.Screenshot import io.tolgee.model.enums.AssignableTranslationState import io.tolgee.model.enums.Scope -import io.tolgee.model.key.Key import io.tolgee.model.translation.Translation import io.tolgee.model.views.KeyWithTranslationsView import io.tolgee.security.ProjectHolder @@ -42,7 +40,6 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.LanguageService -import io.tolgee.service.key.KeyService import io.tolgee.service.key.ScreenshotService import io.tolgee.service.queryBuilders.CursorUtil import io.tolgee.service.security.SecurityService @@ -91,7 +88,6 @@ import java.util.concurrent.TimeUnit class TranslationsController( private val projectHolder: ProjectHolder, private val translationService: TranslationService, - private val keyService: KeyService, private val pagedAssembler: KeysWithTranslationsPagedResourcesAssembler, private val historyPagedAssembler: PagedResourcesAssembler, private val historyModelAssembler: TranslationHistoryModelAssembler, @@ -100,9 +96,9 @@ class TranslationsController( private val securityService: SecurityService, private val authenticationFacade: AuthenticationFacade, private val screenshotService: ScreenshotService, - private val activityHolder: ActivityHolder, private val activityService: ActivityService, private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager, + private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade, ) : IController { @GetMapping(value = ["/{languages}"]) @Operation( @@ -183,20 +179,7 @@ When null, resulting file will be a flat key-value object. @RequestBody @Valid dto: SetTranslationsWithKeyDto, ): SetTranslationsResponseModel { - val key = keyService.get(projectHolder.project.id, dto.key, dto.namespace) - securityService.checkLanguageTranslatePermissionsByTag(dto.translations.keys, projectHolder.project.id) - - val modifiedTranslations = translationService.setForKey(key, dto.translations) - - val translations = - dto.languagesToReturn - ?.let { languagesToReturn -> - translationService.findForKeyByLanguages(key, languagesToReturn) - .associateBy { it.language.tag } - } - ?: modifiedTranslations - - return getSetTranslationsResponse(key, translations) + return createOrUpdateTranslationsFacade.setTranslations(dto) } @PostMapping("") @@ -209,16 +192,7 @@ When null, resulting file will be a flat key-value object. @RequestBody @Valid dto: SetTranslationsWithKeyDto, ): SetTranslationsResponseModel { - val key = - keyService.find(projectHolder.projectEntity.id, dto.key, dto.namespace)?.also { - activityHolder.activity = ActivityType.SET_TRANSLATIONS - } ?: let { - checkKeyEditScope() - activityHolder.activity = ActivityType.CREATE_KEY - keyService.create(projectHolder.projectEntity, dto.key, dto.namespace) - } - val translations = translationService.setForKey(key, dto.translations) - return getSetTranslationsResponse(key, translations) + return createOrUpdateTranslationsFacade.createOrUpdateTranslations(dto) } @PutMapping("/{translationId}/set-state/{state}") @@ -365,21 +339,6 @@ Sorting is not supported for supported. It is automatically sorted from newest t return null } - private fun getSetTranslationsResponse( - key: Key, - translations: Map, - ): SetTranslationsResponseModel { - return SetTranslationsResponseModel( - keyId = key.id, - keyName = key.name, - keyNamespace = key.namespace?.name, - translations = - translations.entries.associate { (languageTag, translation) -> - languageTag to translationModelAssembler.toModel(translation) - }, - ) - } - private fun checkKeyEditScope() { securityService.checkProjectPermission( projectHolder.project.id, diff --git a/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt b/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt index e3d0a02e80..e5c1639f1d 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt @@ -1,5 +1,6 @@ package io.tolgee.component +import com.fasterxml.jackson.databind.ObjectMapper import io.tolgee.activity.ActivityHolder import io.tolgee.activity.data.ActivityType import io.tolgee.constants.Message @@ -22,10 +23,10 @@ import io.tolgee.service.key.ScreenshotService import io.tolgee.service.key.TagService import io.tolgee.service.security.SecurityService import io.tolgee.service.translation.TranslationService -import io.tolgee.util.executeInNewTransaction -import io.tolgee.util.getSafeNamespace +import io.tolgee.util.executeInNewRepeatableTransaction import org.springframework.context.ApplicationContext import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition import kotlin.properties.Delegates class KeyComplexEditHelper( @@ -41,12 +42,14 @@ class KeyComplexEditHelper( private val projectHolder: ProjectHolder = applicationContext.getBean(ProjectHolder::class.java) private val translationService: TranslationService = applicationContext.getBean(TranslationService::class.java) private val tagService: TagService = applicationContext.getBean(TagService::class.java) + private val objectMapper: ObjectMapper = applicationContext.getBean(ObjectMapper::class.java) private val screenshotService: ScreenshotService = applicationContext.getBean(ScreenshotService::class.java) private val activityHolder: ActivityHolder = applicationContext.getBean(ActivityHolder::class.java) private val transactionManager: PlatformTransactionManager = applicationContext.getBean(PlatformTransactionManager::class.java) private val bigMetaService = applicationContext.getBean(BigMetaService::class.java) private val keyMetaService = applicationContext.getBean(KeyMetaService::class.java) + private val keyCustomValuesValidator = applicationContext.getBean(KeyCustomValuesValidator::class.java) private lateinit var key: Key private var modifiedTranslations: Map? = null @@ -56,10 +59,15 @@ class KeyComplexEditHelper( private var areTranslationsModified by Delegates.notNull() private var areStatesModified by Delegates.notNull() private var areTagsModified by Delegates.notNull() - private var isKeyModified by Delegates.notNull() + private var isKeyNameModified by Delegates.notNull() private var isScreenshotDeleted by Delegates.notNull() private var isScreenshotAdded by Delegates.notNull() private var isBigMetaProvided by Delegates.notNull() + private var isNamespaceChanged: Boolean = false + private var isCustomDataChanged: Boolean = false + private var isDescriptionChanged: Boolean = false + private var isIsPluralChanged: Boolean = false + private var newIsPlural by Delegates.notNull() private val languages by lazy { val translationLanguages = dto.translations?.keys ?: setOf() @@ -81,12 +89,15 @@ class KeyComplexEditHelper( } fun doComplexUpdate(): KeyWithDataModel { - return executeInNewTransaction(transactionManager = transactionManager) { + // we don't want phantoms, since we are updating all the translations when isPlural is changed + return executeInNewRepeatableTransaction( + transactionManager = transactionManager, + isolationLevel = TransactionDefinition.ISOLATION_SERIALIZABLE, + ) { prepareData() prepareConditions() setActivityHolder() - - doTranslationUpdate() + doTranslationsUpdate() doStateUpdate() doUpdateTags() doUpdateScreenshots() @@ -106,11 +117,33 @@ class KeyComplexEditHelper( private fun doUpdateKey(): KeyWithDataModel { var edited = key - if (isKeyModified) { + if (requireKeyEditPermission) { key.project.checkKeysEditPermission() + } + + if (isDescriptionChanged) { keyMetaService.getOrCreateForKey(key).apply { description = dto.description } + } + + if (isIsPluralChanged) { + key.isPlural = dto.isPlural!! + key.pluralArgName = dto.pluralArgName ?: key.pluralArgName + translationService.onKeyIsPluralChanged(mapOf(key.id to newPluralArgName), dto.isPlural!!) + keyService.save(key) + } + + if (isCustomDataChanged) { + dto.custom?.let { newCustomValues -> + keyCustomValuesValidator.validate(newCustomValues) + val keyMeta = keyMetaService.getOrCreateForKey(key) + keyMeta.custom = newCustomValues.toMutableMap() + keyMetaService.save(keyMeta) + } + } + + if (isKeyNameModified || isNamespaceChanged) { edited = keyService.edit(key, dto.name, dto.namespace) } @@ -147,7 +180,7 @@ class KeyComplexEditHelper( } } - private fun doTranslationUpdate() { + private fun doTranslationsUpdate() { if (modifiedTranslations != null && areTranslationsModified) { projectHolder.projectEntity.checkTranslationsEditPermission() securityService.checkLanguageTranslatePermissionsByLanguageId( @@ -156,6 +189,8 @@ class KeyComplexEditHelper( ) val modifiedTranslations = getModifiedTranslationsByTag() + val normalizedPlurals = validateAndNormalizePlurals(modifiedTranslations) + val existingTranslationsByTag = getExistingTranslationsByTag() val oldTranslations = modifiedTranslations.map { @@ -166,7 +201,7 @@ class KeyComplexEditHelper( translationService.setForKey( key, oldTranslations = oldTranslations, - translations = modifiedTranslations, + translations = normalizedPlurals, ) translations.forEach { @@ -177,6 +212,17 @@ class KeyComplexEditHelper( } } + private fun validateAndNormalizePlurals(modifiedTranslations: Map): Map { + if (newIsPlural) { + return translationService.validateAndNormalizePlurals(modifiedTranslations, newPluralArgName) + } + return modifiedTranslations + } + + private val newPluralArgName: String? by lazy { + dto.pluralArgName ?: key.pluralArgName + } + private fun getExistingTranslationsByTag() = existingTranslations.map { languageByTag(it.key) to it.value.text }.toMap() @@ -206,7 +252,7 @@ class KeyComplexEditHelper( return } - if (isKeyModified) { + if (isKeyNameModified) { activityHolder.activity = ActivityType.KEY_NAME_EDIT return } @@ -228,7 +274,7 @@ class KeyComplexEditHelper( areTranslationsModified, areStatesModified, areTagsModified, - isKeyModified, + isKeyNameModified, isScreenshotAdded, isScreenshotDeleted, ).sumOf { if (it) 1 as Int else 0 } == 0 @@ -239,15 +285,21 @@ class KeyComplexEditHelper( key.checkInProject() prepareModifiedTranslations() prepareModifiedStates() + newIsPlural = dto.isPlural ?: key.isPlural } private fun prepareConditions() { areTranslationsModified = !modifiedTranslations.isNullOrEmpty() areStatesModified = !modifiedStates.isNullOrEmpty() areTagsModified = dtoTags != null && areTagsModified(key, dtoTags) - isKeyModified = key.name != dto.name || - getSafeNamespace(key.namespace?.name) != getSafeNamespace(dto.namespace) || - key.keyMeta?.description != dto.description + isKeyNameModified = key.name != dto.name + isNamespaceChanged = key.namespace?.name != dto.namespace + isDescriptionChanged = key.keyMeta?.description != dto.description + isIsPluralChanged = + dto.isPlural != null && key.isPlural != dto.isPlural || + (dto.isPlural == true && key.pluralArgName != dto.pluralArgName) + isCustomDataChanged = dto.custom != null && + objectMapper.writeValueAsString(key.keyMeta?.custom) != objectMapper.writeValueAsString(dto.custom) isScreenshotDeleted = !dto.screenshotIdsToDelete.isNullOrEmpty() isScreenshotAdded = !dto.screenshotUploadedImageIds.isNullOrEmpty() || !dto.screenshotsToAdd.isNullOrEmpty() isBigMetaProvided = !dto.relatedKeysInOrder.isNullOrEmpty() @@ -264,6 +316,14 @@ class KeyComplexEditHelper( return !currentTagsContainAllNewTags || !newTagsContainAllCurrentTags } + val requireKeyEditPermission + get() = + isKeyNameModified || + isNamespaceChanged || + isDescriptionChanged || + isIsPluralChanged || + isCustomDataChanged + private fun prepareModifiedTranslations() { modifiedTranslations = dto.translations?.filter { it.value != existingTranslations[it.key]?.text } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt index 4863bb2056..75dfb4ca6c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/apiKey/ApiKeyPermissionsModel.kt @@ -2,6 +2,7 @@ package io.tolgee.hateoas.apiKey import io.swagger.v3.oas.annotations.media.Schema import io.tolgee.hateoas.permission.IPermissionModel +import io.tolgee.hateoas.project.SimpleProjectModel import io.tolgee.model.enums.ProjectPermissionType import io.tolgee.model.enums.Scope import org.springframework.hateoas.RepresentationModel @@ -22,4 +23,5 @@ class ApiKeyPermissionsModel( "granular permissions or if returning API key's permissions", ) override val type: ProjectPermissionType?, + var project: SimpleProjectModel, ) : RepresentationModel(), IPermissionModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt index 1ec265f1af..c9f9430674 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt @@ -1,7 +1,8 @@ package io.tolgee.hateoas.contentDelivery import io.tolgee.dtos.IExportParams -import io.tolgee.dtos.request.export.ExportFormat +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat import io.tolgee.hateoas.ee.contentStorage.ContentStorageModel import io.tolgee.model.enums.TranslationState import org.springframework.hateoas.RepresentationModel @@ -28,4 +29,6 @@ class ContentDeliveryConfigModel( override var filterKeyPrefix: String? = null override var filterState: List? = null override var filterNamespace: List? = null + override var messageFormat: ExportMessageFormat? = null + override var supportArrays: Boolean = false } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueModelAssembler.kt index fd8d43a3ac..a5afee56cf 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueModelAssembler.kt @@ -1,6 +1,6 @@ package io.tolgee.hateoas.dataImport -import io.tolgee.api.v2.controllers.V2ImportController +import io.tolgee.api.v2.controllers.dataImport.V2ImportController import io.tolgee.model.views.ImportFileIssueView import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueParamModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueParamModelAssembler.kt index f31916ad75..ee685265c0 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueParamModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportFileIssueParamModelAssembler.kt @@ -1,6 +1,6 @@ package io.tolgee.hateoas.dataImport -import io.tolgee.api.v2.controllers.V2ImportController +import io.tolgee.api.v2.controllers.dataImport.V2ImportController import io.tolgee.model.views.ImportFileIssueParamView import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportLanguageModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportLanguageModelAssembler.kt index 0708bcef95..5c31b10104 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportLanguageModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportLanguageModelAssembler.kt @@ -1,6 +1,6 @@ package io.tolgee.hateoas.dataImport -import io.tolgee.api.v2.controllers.V2ImportController +import io.tolgee.api.v2.controllers.dataImport.V2ImportController import io.tolgee.model.views.ImportLanguageView import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportSettingsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportSettingsModel.kt new file mode 100644 index 0000000000..890846a7e3 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportSettingsModel.kt @@ -0,0 +1,10 @@ +package io.tolgee.hateoas.dataImport + +import io.tolgee.api.IImportSettings +import org.springframework.hateoas.RepresentationModel +import org.springframework.hateoas.server.core.Relation + +@Relation(collectionRelation = "importSettings", itemRelation = "importSettings") +open class ImportSettingsModel( + settings: IImportSettings, +) : RepresentationModel(), IImportSettings by settings diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModel.kt index 242b075b8c..affbee1b1a 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModel.kt @@ -3,14 +3,18 @@ package io.tolgee.hateoas.dataImport import org.springframework.hateoas.RepresentationModel import org.springframework.hateoas.server.core.Relation +@Suppress("unused") @Relation(collectionRelation = "translations", itemRelation = "translation") open class ImportTranslationModel( val id: Long, val text: String?, val keyName: String, val keyId: Long, + val keyDescription: String?, val conflictId: Long?, val conflictText: String?, val override: Boolean, val resolved: Boolean, + val isPlural: Boolean, + val existingKeyIsPlural: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModelAssembler.kt index 97a3613f64..047ffba535 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/dataImport/ImportTranslationModelAssembler.kt @@ -1,6 +1,7 @@ package io.tolgee.hateoas.dataImport -import io.tolgee.api.v2.controllers.V2ImportController +import io.tolgee.api.v2.controllers.dataImport.V2ImportController +import io.tolgee.formats.convertToIcuPlural import io.tolgee.model.views.ImportTranslationView import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -12,15 +13,30 @@ class ImportTranslationModelAssembler : ImportTranslationModel::class.java, ) { override fun toModel(view: ImportTranslationView): ImportTranslationModel { + val text = getText(view) + return ImportTranslationModel( id = view.id, - text = view.text, + text = text, keyName = view.keyName, keyId = view.keyId, conflictId = view.conflictId, conflictText = view.conflictText, override = view.override, resolved = view.resolvedHash != null, + keyDescription = view.keyDescription, + isPlural = view.plural, + existingKeyIsPlural = view.existingKeyPlural ?: false, ) } + + fun getText(view: ImportTranslationView): String? { + if (view.plural) { + return view.text + } + if (view.existingKeyPlural == true) { + return view.text?.convertToIcuPlural(null) + } + return view.text + } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt index fe3782939e..b2ec32d22f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt @@ -19,4 +19,6 @@ open class KeyModel( example = "This key is used on homepage. It's a label of sign up button.", ) val description: String?, + @Schema(description = "Custom values of the key") + val custom: Map?, ) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModelAssembler.kt index f6a5f09c63..9a68a32380 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModelAssembler.kt @@ -10,11 +10,13 @@ class KeyModelAssembler : RepresentationModelAssemblerSupport TranslationsController::class.java, KeyModel::class.java, ) { + @Suppress("UNCHECKED_CAST") override fun toModel(view: KeyView) = KeyModel( id = view.id, name = view.name, namespace = view.namespace, description = view.description, + custom = view.custom as? Map?, ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt index b83d8033ba..b23671cfbc 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModel.kt @@ -31,4 +31,10 @@ open class KeyWithDataModel( val tags: Set, @Schema(description = "Screenshots of the key") val screenshots: List, + @Schema(description = "If key is pluralized. If it will be reflected in the editor") + val isPlural: Boolean, + @Schema(description = "The argument name for the plural") + val pluralArgName: String?, + @Schema(description = "Custom values of the key") + val custom: Map, ) : RepresentationModel(), Serializable diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModelAssembler.kt index 8556c1b73e..c60d2c9852 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithDataModelAssembler.kt @@ -29,5 +29,8 @@ class KeyWithDataModelAssembler( tags = entity.keyMeta?.tags?.map { tagModelAssembler.toModel(it) }?.toSet() ?: setOf(), screenshots = entity.keyScreenshotReferences.map { it.screenshot }.map { screenshotModelAssembler.toModel(it) }, description = entity.keyMeta?.description, + isPlural = entity.isPlural, + pluralArgName = entity.pluralArgName, + custom = entity.keyMeta?.custom ?: mapOf(), ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithScreenshotsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithScreenshotsModelAssembler.kt index d14aec72c3..047843466d 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithScreenshotsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyWithScreenshotsModelAssembler.kt @@ -31,6 +31,9 @@ class KeyWithScreenshotsModelAssembler( tags = entity.keyMeta?.tags?.map { tagModelAssembler.toModel(it) }?.toSet() ?: setOf(), screenshots = screenshots.map { screenshotModelAssembler.toModel(it) }, description = entity.keyMeta?.description, + isPlural = entity.isPlural, + pluralArgName = entity.pluralArgName, + custom = entity.keyMeta?.custom ?: mapOf(), ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt index 5fc3b06fa3..4af7044802 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModel.kt @@ -24,4 +24,6 @@ open class ProjectModel( @Schema(description = "Current user's direct permission", example = "MANAGE") val directPermission: PermissionModel?, val computedPermission: ComputedPermissionModel, + @Schema(description = "Whether to disable ICU placeholder visualization in the editor and it's support.") + var icuPlaceholders: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt index 34d6fea070..79232d76dd 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectModelAssembler.kt @@ -49,9 +49,10 @@ class ProjectModelAssembler( avatar = avatarService.getAvatarLinks(view.avatarHash), organizationRole = view.organizationRole, organizationOwner = view.organizationOwner.let { simpleOrganizationModelAssembler.toModel(it) }, - baseLanguage = baseLanguage?.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, + baseLanguage = baseLanguage.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), + icuPlaceholders = view.icuPlaceholders, ).add(link).also { model -> model.add(linkTo { get(view.organizationOwner.slug) }.withRel("organizationOwner")) } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt index 0d1312c4e8..6067b0a13e 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModel.kt @@ -31,4 +31,6 @@ open class ProjectWithStatsModel( val computedPermission: ComputedPermissionModel, val stats: ProjectStatistics, val languages: List, + @Schema(description = "Whether to disable ICU placeholder visualization in the editor and it's support.") + var icuPlaceholders: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt index 00ed4ecb56..30678c6e7b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/ProjectWithStatsModelAssembler.kt @@ -52,12 +52,13 @@ class ProjectWithStatsModelAssembler( slug = view.slug, avatar = avatarService.getAvatarLinks(view.avatarHash), organizationRole = view.organizationRole, - baseLanguage = baseLanguage?.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, + baseLanguage = baseLanguage.let { languageModelAssembler.toModel(LanguageDto.fromEntity(it, it.id)) }, organizationOwner = view.organizationOwner.let { simpleOrganizationModelAssembler.toModel(it) }, directPermission = view.directPermission?.let { permissionModelAssembler.toModel(it) }, computedPermission = computedPermissionModelAssembler.toModel(computedPermissions), stats = view.stats, languages = view.languages.map { languageModelAssembler.toModel(it) }, + icuPlaceholders = view.icuPlaceholders, ).add(link).also { model -> view.organizationOwner.slug.let { model.add(linkTo { get(it) }.withRel("organizationOwner")) diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModel.kt index 9ba2785262..b7ebc29c9f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModel.kt @@ -14,4 +14,5 @@ open class SimpleProjectModel( val slug: String?, val avatar: Avatar?, val baseLanguage: LanguageModel?, + var icuPlaceholders: Boolean, ) : RepresentationModel() diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModelAssembler.kt index ad17036fba..178fc316e8 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/SimpleProjectModelAssembler.kt @@ -1,10 +1,11 @@ package io.tolgee.hateoas.project +import io.tolgee.api.ISimpleProject import io.tolgee.api.v2.controllers.V2ProjectsController import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.hateoas.language.LanguageModelAssembler -import io.tolgee.model.Project import io.tolgee.service.AvatarService +import io.tolgee.service.LanguageService import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport import org.springframework.stereotype.Component @@ -12,23 +13,26 @@ import org.springframework.stereotype.Component class SimpleProjectModelAssembler( private val languageModelAssembler: LanguageModelAssembler, private val avatarService: AvatarService, -) : RepresentationModelAssemblerSupport( + private val languageService: LanguageService, +) : RepresentationModelAssemblerSupport( V2ProjectsController::class.java, SimpleProjectModel::class.java, ) { - override fun toModel(entity: Project): SimpleProjectModel { + override fun toModel(project: ISimpleProject): SimpleProjectModel { return SimpleProjectModel( - id = entity.id, - name = entity.name, - description = entity.description, - slug = entity.slug, - avatar = avatarService.getAvatarLinks(entity.avatarHash), + id = project.id, + name = project.name, + description = project.description, + slug = project.slug, + avatar = avatarService.getAvatarLinks(project.avatarHash), + // it's cached so it's fast baseLanguage = - entity.baseLanguage?.let { + languageService.getProjectLanguages(project.id).find { it.base }?.let { languageModelAssembler.toModel( LanguageDto.fromEntity(it, it.id), ) }, + icuPlaceholders = project.icuPlaceholders, ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt index ccef171e50..88baad4f9f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModel.kt @@ -13,6 +13,10 @@ open class KeyWithTranslationsModel( val keyId: Long, @Schema(description = "Name of key", example = "this_is_super_key") val keyName: String, + @Schema(description = "Is this key a plural?", example = "true") + val keyIsPlural: Boolean, + @Schema(description = "The placeholder name for plural parameter", example = "value") + val keyPluralArgName: String?, @Schema(description = "The namespace id of the key", example = "100000282") val keyNamespaceId: Long?, @Schema(description = "The namespace of the key", example = "homepage") diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt index f6ef55501d..271695260d 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/KeyWithTranslationsModelAssembler.kt @@ -21,6 +21,8 @@ class KeyWithTranslationsModelAssembler( keyId = view.keyId, keyName = view.keyName, keyNamespaceId = view.keyNamespaceId, + keyIsPlural = view.keyIsPlural, + keyPluralArgName = view.keyPluralArgName, keyNamespace = view.keyNamespace, keyDescription = view.keyDescription, keyTags = view.keyTags.map { tagModelAssembler.toModel(it) }, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/SetTranslationsResponseModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/SetTranslationsResponseModel.kt index c77091a8de..0f3cba5f11 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/SetTranslationsResponseModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/translations/SetTranslationsResponseModel.kt @@ -13,6 +13,7 @@ open class SetTranslationsResponseModel( val keyName: String, @Schema(description = "The namespace of the key", example = "homepage") val keyNamespace: String?, + val keyIsPlural: Boolean, @Schema( description = "Translations object containing values updated in this request", example = "{\"en\": {\"id\": 100000003, \"text\": \"This is super translation!\" }}", diff --git a/backend/app/src/main/kotlin/io/tolgee/ExceptionHandlers.kt b/backend/app/src/main/kotlin/io/tolgee/ExceptionHandlers.kt index 0e31267797..3b6d951cdd 100644 --- a/backend/app/src/main/kotlin/io/tolgee/ExceptionHandlers.kt +++ b/backend/app/src/main/kotlin/io/tolgee/ExceptionHandlers.kt @@ -139,6 +139,7 @@ class ExceptionHandlers { @ExceptionHandler(EntityNotFoundException::class) fun handleServerError(ex: EntityNotFoundException?): ResponseEntity { + logger.debug("Entity not found", ex) return ResponseEntity(ErrorResponseBody(Message.RESOURCE_NOT_FOUND.code, null), HttpStatus.NOT_FOUND) } @@ -155,7 +156,8 @@ class ExceptionHandlers { ) @ExceptionHandler(NotFoundException::class) fun handleNotFound(ex: NotFoundException): ResponseEntity { - return ResponseEntity(ErrorResponseBody(ex.msg!!.code, null), HttpStatus.NOT_FOUND) + logger.debug(ex.message, ex) + return ResponseEntity(ErrorResponseBody(ex.msg.code, null), HttpStatus.NOT_FOUND) } @ExceptionHandler(MaxUploadSizeExceededException::class) diff --git a/backend/app/src/main/resources/application-e2e.yaml b/backend/app/src/main/resources/application-e2e.yaml index e712acd1e9..a64e141e29 100644 --- a/backend/app/src/main/resources/application-e2e.yaml +++ b/backend/app/src/main/resources/application-e2e.yaml @@ -61,6 +61,9 @@ tolgee: enabled: false batch: concurrency: 10 + postgres-autostart: + container-name: tolgee-postgres-e2e + port: 58532 server: port: 8201 error: diff --git a/backend/app/src/test/kotlin/io/tolgee/StreamingBodyDatabasePoolHealthTest.kt b/backend/app/src/test/kotlin/io/tolgee/StreamingBodyDatabasePoolHealthTest.kt index 50b2e9a9e4..cf5d1fa8d2 100644 --- a/backend/app/src/test/kotlin/io/tolgee/StreamingBodyDatabasePoolHealthTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/StreamingBodyDatabasePoolHealthTest.kt @@ -56,29 +56,24 @@ class StreamingBodyDatabasePoolHealthTest : ProjectAuthControllerTest("/v2/proje fun `streaming responses do not cause a database connection pool exhaustion`() { // there is the bug in spring, co it throws the concurrent modification exception // to avoid this, we will retry the test until it passes, - // but we will also increase the sleep time between requests to make it more probable to pass // I know, it's ugly. Sorry. If you have time to spare, remove the repeats and the sleep, maybe it will pass // in future spring versions // https://github.com/spring-projects/spring-security/issues/9175 - var sleepBetweenMs = 0L retry( retries = 100, exceptionMatcher = { it is ConcurrentModificationException || it is IllegalStateException }, ) { - try { - val hikariDataSource = dataSource as HikariDataSource - val pool = hikariDataSource.hikariPoolMXBean + val hikariDataSource = dataSource as HikariDataSource + val pool = hikariDataSource.hikariPoolMXBean + waitForNotThrowing { + pool.idleConnections.assert.isGreaterThan(70) + } + repeat(50) { + performProjectAuthGet("export").andIsOk + } + waitForNotThrowing(pollTime = 50, timeout = 5000) { pool.idleConnections.assert.isGreaterThan(70) - repeat(50) { - performProjectAuthGet("export").andIsOk - Thread.sleep(sleepBetweenMs) - } - waitForNotThrowing(pollTime = 50, timeout = 5000) { - pool.idleConnections.assert.isGreaterThan(70) - } - } finally { - sleepBetweenMs += 10 } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ApiKeyControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ApiKeyControllerTest.kt index ccf3b26532..698eacb72d 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ApiKeyControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ApiKeyControllerTest.kt @@ -228,6 +228,9 @@ class ApiKeyControllerTest : AuthorizedControllerTest() { node("translateLanguageIds").isNull() node("viewLanguageIds").isNull() node("stateChangeLanguageIds").isNull() + node("project") { + node("id").isNumber.isGreaterThan(0.toBigDecimal()) + } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerMtTest.kt similarity index 88% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerMtTest.kt index f56e05259a..3727e4c68b 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerMtTest.kt @@ -51,10 +51,9 @@ import software.amazon.awssdk.services.translate.TranslateClient import software.amazon.awssdk.services.translate.model.TranslateTextRequest import software.amazon.awssdk.services.translate.model.TranslateTextResponse import java.util.* -import kotlin.system.measureTimeMillis import software.amazon.awssdk.services.translate.model.Formality as AwsFormality -class TranslationSuggestionControllerTest : ProjectAuthControllerTest("/v2/projects/") { +class TranslationSuggestionControllerMtTest : ProjectAuthControllerTest("/v2/projects/") { lateinit var testData: SuggestionTestData @Autowired @@ -189,61 +188,6 @@ class TranslationSuggestionControllerTest : ProjectAuthControllerTest("/v2/proje projectSupplier = { testData.projectBuilder.self } } - @Test - @ProjectJWTAuthTestMethod - fun `it suggests from TM with keyId`() { - saveTestData() - performAuthPost( - "/v2/projects/${project.id}/suggest/translation-memory", - SuggestRequestDto(keyId = testData.thisIsBeautifulKey.id, targetLanguageId = testData.germanLanguage.id), - ).andIsOk.andPrettyPrint.andAssertThatJson { - node("_embedded.translationMemoryItems") { - node("[0]") { - node("targetText").isEqualTo("Das ist schön") - node("baseText").isEqualTo("This is beautiful") - node("keyName").isEqualTo("key 2") - node("similarity").isEqualTo("0.6296296") - } - } - node("page.totalElements").isEqualTo(1) - } - } - - @Test - @ProjectJWTAuthTestMethod - fun `it suggests from TM with baseText`() { - saveTestData() - performAuthPost( - "/v2/projects/${project.id}/suggest/translation-memory", - SuggestRequestDto(baseText = "This is beautiful", targetLanguageId = testData.germanLanguage.id), - ).andIsOk.andPrettyPrint.andAssertThatJson { - node("_embedded.translationMemoryItems") { - node("[0]") { - node("targetText").isEqualTo("Das ist schön") - node("baseText").isEqualTo("This is beautiful") - node("keyName").isEqualTo("key 2") - node("similarity").isEqualTo("1.0") - } - } - node("page.totalElements").isEqualTo(3) - } - } - - @Test - @ProjectJWTAuthTestMethod - fun `it suggests from TM fast enough`() { - testData.generateLotOfData() - saveTestData() - val time = - measureTimeMillis { - performAuthPost( - "/v2/projects/${project.id}/suggest/translation-memory", - SuggestRequestDto(keyId = testData.beautifulKey.id, targetLanguageId = testData.germanLanguage.id), - ).andIsOk - } - assertThat(time).isLessThan(1500) - } - @Test @ProjectJWTAuthTestMethod fun `it suggests machine translations with keyId`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTmTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTmTest.kt new file mode 100644 index 0000000000..4990603864 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translationSuggestionController/TranslationSuggestionControllerTmTest.kt @@ -0,0 +1,182 @@ +package io.tolgee.api.v2.controllers.translationSuggestionController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.SuggestionTestData +import io.tolgee.dtos.request.SuggestRequestDto +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andPrettyPrint +import io.tolgee.fixtures.node +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.system.measureTimeMillis + +class TranslationSuggestionControllerTmTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: SuggestionTestData + + private fun saveTestData() { + testDataService.saveTestData(testData.root) + userAccount = testData.user + } + + @BeforeEach + fun initTestData() { + testData = SuggestionTestData() + projectSupplier = { testData.projectBuilder.self } + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM with keyId`() { + saveTestData() + performAuthPost( + "/v2/projects/${project.id}/suggest/translation-memory", + SuggestRequestDto(keyId = testData.thisIsBeautifulKey.id, targetLanguageId = testData.germanLanguage.id), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("_embedded.translationMemoryItems") { + node("[0]") { + node("targetText").isEqualTo("Das ist schön") + node("baseText").isEqualTo("This is beautiful") + node("keyName").isEqualTo("key 2") + node("similarity").isEqualTo("0.6296296") + } + } + node("page.totalElements").isEqualTo(1) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM with baseText`() { + saveTestData() + performAuthPost( + "/v2/projects/${project.id}/suggest/translation-memory", + SuggestRequestDto(baseText = "This is beautiful", targetLanguageId = testData.germanLanguage.id), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("_embedded.translationMemoryItems") { + node("[0]") { + node("targetText").isEqualTo("Das ist schön") + node("baseText").isEqualTo("This is beautiful") + node("keyName").isEqualTo("key 2") + node("similarity").isEqualTo("1.0") + } + } + node("page.totalElements").isEqualTo(3) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM only plurals for plural using keyId`() { + val pluralKeys = testData.addPluralKeys() + saveTestData() + performTmSuggestionExpectSingleResult( + keyId = pluralKeys.truePlural.id, + expectedResultKeyName = pluralKeys.sameTruePlural.name, + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM only plurals for non plural using keyId`() { + val pluralKeys = testData.addPluralKeys() + saveTestData() + performTmSuggestionExpectSingleResult( + keyId = pluralKeys.falsePlural.id, + expectedResultKeyName = pluralKeys.sameFalsePlural.name, + ) + } + + @ProjectJWTAuthTestMethod + @Test + fun `it suggests from TM only plurals for plural using baseText`() { + val pluralKeys = testData.addPluralKeys() + saveTestData() + performTmSuggestionExpectTwoResults( + baseText = + testData.projectBuilder + .getTranslation(pluralKeys.truePlural, testData.englishLanguage.tag)!!.text, + baseIsPlural = true, + expectedResultValue = + testData.projectBuilder + .getTranslation(pluralKeys.truePlural, testData.germanLanguage.tag)!!.text!!, + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM only plurals for non plural using baseText`() { + val pluralKeys = testData.addPluralKeys() + saveTestData() + performTmSuggestionExpectTwoResults( + baseText = + testData.projectBuilder + .getTranslation(pluralKeys.falsePlural, testData.englishLanguage.tag)!!.text, + baseIsPlural = false, + expectedResultValue = + testData.projectBuilder + .getTranslation(pluralKeys.falsePlural, testData.germanLanguage.tag)!!.text!!, + ) + } + + private fun performTmSuggestionExpectSingleResult( + keyId: Long? = null, + expectedResultKeyName: String, + ) { + performAuthPost( + "/v2/projects/${project.id}/suggest/translation-memory", + SuggestRequestDto( + keyId = keyId, + targetLanguageId = testData.germanLanguage.id, + ), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("_embedded.translationMemoryItems") { + node("[0]") { + node("keyName").isEqualTo(expectedResultKeyName) + } + } + node("page.totalElements").isEqualTo(1) + } + } + + private fun performTmSuggestionExpectTwoResults( + keyId: Long? = null, + baseText: String? = null, + baseIsPlural: Boolean? = null, + expectedResultValue: String, + ) { + performAuthPost( + "/v2/projects/${project.id}/suggest/translation-memory", + SuggestRequestDto( + keyId = keyId, + baseText = baseText, + isPlural = baseIsPlural ?: false, + targetLanguageId = testData.germanLanguage.id, + ), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("_embedded.translationMemoryItems") { + node("[0]") { + node("targetText").isString.isEqualTo(expectedResultValue) + } + } + node("page.totalElements").isEqualTo(2) + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `it suggests from TM fast enough`() { + testData.generateLotOfData() + saveTestData() + val time = + measureTimeMillis { + performAuthPost( + "/v2/projects/${project.id}/suggest/translation-memory", + SuggestRequestDto(keyId = testData.beautifulKey.id, targetLanguageId = testData.germanLanguage.id), + ).andIsOk + } + assertThat(time).isLessThan(1500) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt index 3f5cb2e079..92d490c44d 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerModificationTest.kt @@ -8,12 +8,14 @@ import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsForbidden import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.andPrettyPrint import io.tolgee.fixtures.isValidId import io.tolgee.model.enums.Scope import io.tolgee.model.enums.TranslationState import io.tolgee.model.translation.Translation import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -51,6 +53,114 @@ class TranslationsControllerModificationTest : ProjectAuthControllerTest("/v2/pr } } + @ProjectJWTAuthTestMethod + @Test + fun `validates plurals on set for existing`() { + testData.addPluralKey() + saveTestData() + performUpdatePluralKey("Not a plural").andIsBadRequest + } + + @ProjectJWTAuthTestMethod + @Test + fun `normalizes plurals on set for existing`() { + testData.addPluralKey() + saveTestData() + performUpdatePluralKey("Hello! {count, plural, other {test}}").andIsOk + .andAssertThatJson { + node("translations.en.text").isString.isEqualTo("{count, plural,\nother {Hello! test}\n}") + node("keyIsPlural").isBoolean.isTrue + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `doesnt touch isPlural if not plural`() { + saveTestData() + performProjectAuthPut( + "/translations", + SetTranslationsWithKeyDto( + "A key", + null, + mutableMapOf("en" to "English"), + ), + ).andIsOk + .andAssertThatJson { + node("translations.en.text").isEqualTo("English") + node("keyIsPlural").isBoolean.isFalse + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `works with no other`() { + testData.addPluralKey() + saveTestData() + performUpdatePluralKey( + "{count, plural,\n" + + "one {test}\n" + + "other{}}", + ).andIsOk + .andAssertThatJson { + node("translations.en.text").isString.isEqualTo("{count, plural,\none {test}\nother {}\n}") + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `works with empty string`() { + val key = testData.addPluralKey() + saveTestData() + performUpdatePluralKey("").andIsOk + .andAssertThatJson { + node("translations.en.text").isEqualTo(null) + } + + executeInNewTransaction { + keyService.get(key.id).translations.find { it.language.tag == "en" }!!.text.assert.isNull() + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `creates key with correct isPlural for new keys`() { + saveTestData() + performProjectAuthPost( + "/translations", + SetTranslationsWithKeyDto( + "plural_key", + null, + mutableMapOf( + "en" to "Hi! {count, plural, other {test}}", + "de" to "Nicht ein Plural", + ), + ), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("translations.en.text").isString.isEqualTo("{count, plural,\nother {Hi! test}\n}") + node("translations.de.text").isString.isEqualTo("{count, plural,\nother {Nicht ein Plural}\n}") + node("keyIsPlural").isBoolean.isTrue + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `creates key with correct isPlural for new keys (not plural)`() { + saveTestData() + performProjectAuthPost( + "/translations", + SetTranslationsWithKeyDto( + "other key", + null, + mutableMapOf( + "de" to "Nicht ein Plural", + ), + ), + ).andIsOk.andPrettyPrint.andAssertThatJson { + node("translations.de.text").isString.isEqualTo("Nicht ein Plural") + node("keyIsPlural").isBoolean.isFalse + } + } + @ProjectJWTAuthTestMethod @Test fun `returns selected languages after set`() { @@ -295,4 +405,14 @@ class TranslationsControllerModificationTest : ProjectAuthControllerTest("/v2/pr userAccount = testData.user this.projectSupplier = { testData.project } } + + private fun performUpdatePluralKey(value: String?) = + performProjectAuthPut( + "/translations", + SetTranslationsWithKeyDto( + "plural_key", + null, + mutableMapOf("en" to value), + ), + ) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerViewTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerViewTest.kt index 6c2122cc2d..7b748176e8 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerViewTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerViewTest.kt @@ -331,6 +331,18 @@ class TranslationsControllerViewTest : ProjectAuthControllerTest("/v2/projects/" } } + @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) + @Test + fun `returns correct plural values`() { + testData.addPlural() + testDataService.saveTestData(testData.root) + userAccount = testData.user + performProjectAuthGet("/translations").andPrettyPrint.andIsOk.andAssertThatJson { + node("_embedded.keys[2].keyIsPlural").isEqualTo(true) + node("_embedded.keys[2].keyPluralArgName").isEqualTo("count") + } + } + @ProjectApiKeyAuthTestMethod(scopes = [Scope.KEYS_VIEW]) @Test fun `returns select all keys`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt index dfef383d70..9de9f0bb4c 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerAddFilesTest.kt @@ -1,7 +1,7 @@ package io.tolgee.api.v2.controllers.v2ImportController +import io.tolgee.ProjectAuthControllerTest import io.tolgee.development.testDataBuilder.data.dataImport.ImportCleanTestData -import io.tolgee.fixtures.AuthorizedRequestFactory import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsOk @@ -11,21 +11,20 @@ import io.tolgee.fixtures.node import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType -import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import io.tolgee.util.InMemoryFileStorage +import io.tolgee.util.performImport import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Value import org.springframework.core.io.Resource -import org.springframework.mock.web.MockMultipartFile import org.springframework.test.web.servlet.ResultActions -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.transaction.annotation.Transactional @Transactional -class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { +class V2ImportControllerAddFilesTest : ProjectAuthControllerTest("/v2/projects/") { @Value("classpath:import/zipOfJsons.zip") lateinit var zipOfJsons: Resource @@ -79,7 +78,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { commitTransaction() val fileName = "zipOfUnknown.zip" - performImport(projectId = base.project.id, mapOf(Pair(fileName, zipOfUnknown))).andAssertThatJson { + performImport(projectId = base.project.id, listOf(Pair(fileName, zipOfUnknown))).andAssertThatJson { node("errors[2].code").isEqualTo("cannot_parse_file") } @@ -92,7 +91,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { commitTransaction() val fileName = "zipOfUnknown.zip" - performImport(projectId = base.project.id, mapOf(Pair(fileName, zipOfUnknown))) + performImport(projectId = base.project.id, listOf(Pair(fileName, zipOfUnknown))) doesStoredFileExists(fileName, base.project.id).assert.isFalse() } @@ -108,7 +107,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { fun `it handles po file`() { val base = dbPopulator.createBase(generateUniqueString()) - performImport(projectId = base.project.id, mapOf(Pair("example.po", poFile))) + performImport(projectId = base.project.id, listOf(Pair("example.po", poFile))) .andPrettyPrint.andAssertThatJson { node("result._embedded.languages").isArray.hasSize(1) }.andReturn() @@ -118,6 +117,9 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { importService.find(base.project.id, base.userAccount.id)?.let { assertThat(it.files).hasSize(1) assertThat(it.files[0].languages[0].translations).hasSize(8) + // correctly assigns isPlural + assertThat(it.files[0].keys[4].translations[0].isPlural).isTrue() + assertThat(it.files[0].keys[3].translations[0].isPlural).isFalse() } } @@ -125,7 +127,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { fun `it handles xliff file`() { val base = dbPopulator.createBase(generateUniqueString()) - performImport(projectId = base.project.id, mapOf(Pair("example.xliff", xliffFile))) + performImport(projectId = base.project.id, listOf(Pair("example.xliff", xliffFile))) .andPrettyPrint.andAssertThatJson { node("result._embedded.languages").isArray.hasSize(2) }.andReturn() @@ -135,7 +137,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { fun `it returns error when json could not be parsed`() { val base = dbPopulator.createBase(generateUniqueString()) - performImport(projectId = base.project.id, mapOf(Pair("error.json", errorJson))) + performImport(projectId = base.project.id, listOf(Pair("error.json", errorJson))) .andIsOk.andAssertThatJson { node("errors[0].code").isEqualTo("cannot_parse_file") node("errors[0].params[0]").isEqualTo("error.json") @@ -147,7 +149,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { fun `it throws when more then 100 languages`() { val base = dbPopulator.createBase(generateUniqueString()) - val data = (1..101).associate { "simple$it.json" as String? to simpleJson } + val data = (1..101).map { "simple$it.json" to simpleJson } performImport(projectId = base.project.id, data) .andIsBadRequest.andPrettyPrint.andAssertThatJson { @@ -156,24 +158,26 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { } @Test + @ProjectJWTAuthTestMethod fun `it imports empty keys`() { - val base = dbPopulator.createBase(generateUniqueString()) - - performImport(projectId = base.project.id, mapOf("empty-keys.json" to emptyKeys)) + performImport(projectId = project.id, listOf("empty-keys.json" to emptyKeys)) .andIsOk.andPrettyPrint entityManager.clear() - importService.find(base.project.id, base.userAccount.id)?.let { + importService.find(project.id, userAccount!!.id)?.let { assertThat(it.files[0].keys).hasSize(1) } + + val path = "import/apply?forceMode=OVERRIDE" + performProjectAuthPut(path, null).andIsOk } @Test fun `it imports nested keys with provided delimiter`() { val base = dbPopulator.createBase(generateUniqueString()) - performImport(projectId = base.project.id, mapOf("nested.json" to nested), mapOf("structureDelimiter" to ";")) + performImport(projectId = base.project.id, listOf("nested.json" to nested), mapOf("structureDelimiter" to ";")) .andIsOk entityManager.clear() @@ -188,7 +192,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { val base = dbPopulator.createBase(generateUniqueString()) commitTransaction() - performImport(projectId = base.project.id, mapOf(Pair("zipOfJsons.zip", zipOfJsons))) + performImport(projectId = base.project.id, listOf(Pair("zipOfJsons.zip", zipOfJsons))) .andAssertThatJson { node("result._embedded.languages").isArray.hasSize(3) } @@ -204,7 +208,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { executeInNewTransaction { performImport( projectId = base.project.id, - mapOf(Pair("tooLongTranslation.json", tooLongTranslation)), + listOf(Pair("tooLongTranslation.json", tooLongTranslation)), ).andIsOk } @@ -244,7 +248,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { executeInNewTransaction { performImport( projectId = base.project.id, - mapOf(Pair("namespaces.zip", namespacesZip)), + listOf(Pair("namespaces.zip", namespacesZip)), ).andIsOk } @@ -268,7 +272,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { executeInNewTransaction { performImport( projectId = base.project.id, - mapOf(Pair("namespaces.zip", namespacesMacZip)), + listOf(Pair("namespaces.zip", namespacesMacZip)), ).andIsOk } @@ -279,30 +283,6 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { } } - @Test - fun `stores issue with too long value`() { - val base = dbPopulator.createBase(generateUniqueString()) - commitTransaction() - - executeInNewTransaction { - performImport( - projectId = base.project.id, - mapOf(Pair("tooLongErrorParamValue.json", tooLongErrorParamValue)), - ).andIsOk - } - - executeInNewTransaction { - importService.find(base.project.id, base.userAccount.id)!!.let { - assertThat(it.files[0].issues[0].params[0].value).isEqualTo("not_string") - assertThat(it.files[0].issues[0].params[2].value).isEqualTo( - "[Lorem ipsum dolor sit amet," + - " consectetur adipiscing elit. Suspendisse" + - " ac ultricies tortor. Integer ac...", - ) - } - } - } - @Test fun `correctly computes conflicts on import`() { val testData = ImportCleanTestData() @@ -312,7 +292,7 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { executeInNewTransaction { performImport( projectId = testData.project.id, - mapOf(Pair("importWithConflicts.zip", importWithConflicts)), + listOf(Pair("importWithConflicts.zip", importWithConflicts)), ).andIsOk.andAssertThatJson { node("result.page.totalElements").isEqualTo(2) node("result._embedded.languages[0].conflictCount").isEqualTo(1) @@ -345,29 +325,10 @@ class V2ImportControllerAddFilesTest : AuthorizedControllerTest() { private fun performImport( projectId: Long, - files: Map?, + files: List>?, params: Map = mapOf(), ): ResultActions { - val builder = - MockMvcRequestBuilders - .multipart("/v2/projects/$projectId/import?${mapToQueryString(params)}") - - files?.forEach { - builder.file( - MockMultipartFile( - "files", - it.key, - "application/zip", - it.value.file.readBytes(), - ), - ) - } - loginAsAdminIfNotLogged() - return mvc.perform(AuthorizedRequestFactory.addToken(builder)) - } - - fun mapToQueryString(map: Map): String { - return map.entries.joinToString("&") { "${it.key}=${it.value}" } + return performImport(mvc, projectId, files, params) } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerConflictsBetweenFilesTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerConflictsBetweenFilesTest.kt new file mode 100644 index 0000000000..94d34c8419 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerConflictsBetweenFilesTest.kt @@ -0,0 +1,328 @@ +package io.tolgee.api.v2.controllers.v2ImportController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.dataImport.ImportCleanTestData +import io.tolgee.fixtures.AuthorizedRequestFactory +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.io.Resource +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import java.util.function.Consumer + +class V2ImportControllerConflictsBetweenFilesTest : ProjectAuthControllerTest("/v2/projects/") { + @Value("classpath:import/simple.json") + lateinit var simpleJson: Resource + + @Value("classpath:import/almost_simple.json") + lateinit var almostSimpleJson: Resource + + @Value("classpath:import/apple/stringsStringsDictConflict/Localizable.strings") + lateinit var stringsFile: Resource + + @Value("classpath:import/apple/stringsStringsDictConflict/Localizable.stringsdict") + lateinit var stringsdictFile: Resource + + @Test + @ProjectApiKeyAuthTestMethod + fun `can add file with same keys and same language`() { + val testData = prepareTestData() + performImportWithConflicts(testData).andIsOk.andAssertThatJson { + node("result._embedded.languages") { + node("[0]") { + node("existingLanguageTag").isEqualTo("en") + node("totalCount").isEqualTo(1) + node("importFileIssueCount").isEqualTo(0) + } + node("[1]") { + node("existingLanguageTag").isEqualTo("en") + node("totalCount").isEqualTo(0) + node("importFileIssueCount").isEqualTo(1) + } + } + } + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `works with namespace changes (file with conflicts)`() { + val testData = prepareTestData() + val importResult = createConflictingImport(testData) + + selectNamespace(importResult.file2Id) + assertNoConflicts() + resetNamespace(fileId = importResult.file2Id) + assertHasConflicts() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `works with language changes`() { + val testData = prepareTestData() + val importResult = createConflictingImport(testData) + + selectLanguage(importResult.language2Id, testData.french.id) + assertNoConflicts() + selectLanguage(importResult.language2Id, testData.english.id) + assertHasConflicts() + resetLanguage(importResult.language2Id) + assertNoConflicts() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `works with language deletion`() { + val testData = prepareTestData() + val importResult = createConflictingImport(testData) + + deleteLanguage(importResult.language1Id) + + assertNoConflicts() + } + + private fun deleteLanguage(languageId: Long) { + performProjectAuthDelete( + "import/result/languages/$languageId", + ).andIsOk + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `handles strings & stringsdict collision`() { + val testData = prepareTestData() + performImportWithAppleConflicts(testData) + assertOnlyStringdictKeyToImport() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `resets apple conflict when existing language selected`() { + val testData = prepareTestData() + val data = createAppleConflictingImport(testData) + selectLanguage(data.language2Id, testData.french.id) + assertAllToImport() + selectLanguage(data.language2Id, testData.english.id) + assertOnlyStringdictKeyToImport() + selectLanguage(data.language1Id, testData.french.id) + assertAllToImport() + selectLanguage(data.language2Id, testData.french.id) + assertOnlyStringdictKeyToImport() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `resets apple conflict on existing language reset`() { + val testData = prepareTestData() + val data = createAppleConflictingImport(testData) + resetLanguage(data.language2Id) + assertAllToImport() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `resets apple conflict when language deleted`() { + val testData = prepareTestData() + val data = createAppleConflictingImport(testData) + deleteLanguage(data.language2Id) + assertFirstToImport() + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `resets apple conflict when namespace selected`() { + val testData = prepareTestData() + val data = createAppleConflictingImport(testData) + selectNamespace(data.file2Id) + assertAllToImport() + resetNamespace(data.file2Id) + assertOnlyStringdictKeyToImport() + } + + private fun resetLanguage(importLanguageId: Long) { + performProjectAuthPut( + "import/result/languages/$importLanguageId/reset-existing", + ).andIsOk + } + + private fun selectLanguage( + importLanguageId: Long, + existingLanguage: Long, + ) { + performProjectAuthPut( + "import/result/languages/$importLanguageId/select-existing/$existingLanguage", + ).andIsOk + } + + private fun ResultActions.parseData(): ImportResult { + var file1Id: Long? = null + var file2Id: Long? = null + var language1Id: Long? = null + var language2Id: Long? = null + andIsOk.andAssertThatJson { + node("result._embedded.languages[0].importFileId").isNumber.satisfies( + Consumer { + file1Id = it.toLong() + }, + ) + node("result._embedded.languages[1].importFileId").isNumber.satisfies( + Consumer { + file2Id = it.toLong() + }, + ) + node("result._embedded.languages[0].id").isNumber.satisfies( + Consumer { + language1Id = it.toLong() + }, + ) + node("result._embedded.languages[1].id").isNumber.satisfies( + Consumer { + language2Id = it.toLong() + }, + ) + }.andIsOk + return ImportResult( + file1Id!!, + file2Id!!, + language1Id!!, + language2Id!!, + ) + } + + private fun createAppleConflictingImport(testData: ImportCleanTestData): ImportResult { + return performImportWithAppleConflicts(testData).parseData() + } + + private fun createConflictingImport(testData: ImportCleanTestData): ImportResult { + return performImportWithConflicts(testData).parseData() + } + + private data class ImportResult( + val file1Id: Long, + val file2Id: Long, + val language1Id: Long, + val language2Id: Long, + ) + + private fun assertNoConflicts() { + performProjectAuthGet("import/result").andIsOk.andAssertThatJson { + node("_embedded.languages[0].totalCount").isEqualTo(1) + node("_embedded.languages[0].importFileIssueCount").isEqualTo(0) + try { + node("_embedded.languages[1].totalCount").isEqualTo(1) + node("_embedded.languages[1].importFileIssueCount").isEqualTo(0) + } catch (e: AssertionError) { + node("_embedded.languages[1]").isAbsent() + } + } + } + + private fun assertOnlyStringdictKeyToImport() { + performProjectAuthGet("import/result").andIsOk.andAssertThatJson { + node("_embedded.languages[0].totalCount").isEqualTo(0) + node("_embedded.languages[0].importFileIssueCount").isEqualTo(0) + node("_embedded.languages[1].totalCount").isEqualTo(1) + node("_embedded.languages[1].importFileIssueCount").isEqualTo(0) + } + } + + private fun assertAllToImport() { + assertFirstToImport() + assertSecondToImport() + } + + private fun assertSecondToImport() { + performProjectAuthGet("import/result").andIsOk.andAssertThatJson { + node("_embedded.languages[1].totalCount").isEqualTo(1) + node("_embedded.languages[1].importFileIssueCount").isEqualTo(0) + } + } + + private fun assertFirstToImport() { + performProjectAuthGet("import/result").andIsOk.andAssertThatJson { + node("_embedded.languages[0].totalCount").isEqualTo(1) + node("_embedded.languages[0].importFileIssueCount").isEqualTo(0) + } + } + + private fun assertHasConflicts() { + performProjectAuthGet("import/result").andIsOk.andAssertThatJson { + node("_embedded.languages[0].totalCount").isEqualTo(1) + node("_embedded.languages[0].importFileIssueCount").isEqualTo(0) + node("_embedded.languages[1].totalCount").isEqualTo(0) + node("_embedded.languages[1].importFileIssueCount").isEqualTo(1) + } + } + + private fun prepareTestData(): ImportCleanTestData { + val testData = ImportCleanTestData() + testDataService.saveTestData(testData.root) + loginAsUser(testData.userAccount.username) + projectSupplier = { testData.projectBuilder.self } + return testData + } + + private fun performImportWithConflicts(testData: ImportCleanTestData) = + performImport( + projectId = testData.project.id, + listOf( + Pair("en.json", simpleJson), + Pair("en.json", almostSimpleJson), + ), + ) + + private fun performImportWithAppleConflicts(testData: ImportCleanTestData) = + performImport( + projectId = testData.project.id, + listOf( + Pair("en.lproj/Localizable.strings", stringsFile), + Pair("en.lproj/Localizable.stringsdict", stringsdictFile), + ), + ) + + private fun performImport( + projectId: Long, + files: List>?, + params: Map = mapOf(), + ): ResultActions { + val builder = + MockMvcRequestBuilders + .multipart("/v2/projects/$projectId/import?${mapToQueryString(params)}") + + files?.forEach { + builder.file( + MockMultipartFile( + "files", + it.first, + "application/zip", + it.second.file.readBytes(), + ), + ) + } + + loginAsAdminIfNotLogged() + return mvc.perform(AuthorizedRequestFactory.addToken(builder)) + } + + fun mapToQueryString(map: Map): String { + return map.entries.joinToString("&") { "${it.key}=${it.value}" } + } + + private fun selectNamespace(fileId: Long) { + performProjectAuthPut( + "import/result/files/$fileId/select-namespace", + mapOf("namespace" to "namespaced"), + ).andIsOk + } + + private fun resetNamespace(fileId: Long) { + performProjectAuthPut( + "import/result/files/$fileId/select-namespace", + mapOf("namespace" to null), + ).andIsOk + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerManipulationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerManipulationTest.kt index 73f6ddc555..dcc63c0948 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerManipulationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerManipulationTest.kt @@ -139,19 +139,6 @@ class V2ImportControllerManipulationTest : ProjectAuthControllerTest("/v2/projec } } - @Test - @ProjectJWTAuthTestMethod - fun `resets language when selecting namespace`() { - val testData = ImportNamespacesTestData() - testDataService.saveTestData(testData.root) - projectSupplier = { testData.project } - userAccount = testData.userAccount - val path = "import/result/files/${testData.defaultNsFile.id}/select-namespace" - performProjectAuthPut(path, mapOf("namespace" to "homepage")).andIsOk - importService.findLanguage(testData.importEnglish.id)!!.existingLanguage.assert.isNull() - importService.findLanguage(testData.importGerman.id)!!.existingLanguage.assert.isNull() - } - @Test @ProjectJWTAuthTestMethod fun `returns all namespaces`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerPluralizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerPluralizationTest.kt new file mode 100644 index 0000000000..7e27c1fbc3 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerPluralizationTest.kt @@ -0,0 +1,58 @@ +package io.tolgee.api.v2.controllers.v2ImportController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.dataImport.ImportPluralizationTestData +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.translation.Translation +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class V2ImportControllerPluralizationTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: ImportPluralizationTestData + + @Test + @ProjectJWTAuthTestMethod + fun `it correctly migrates data`() { + saveTestDataAndApplyImport() + // new value is migrated + getTranslation("cs", "existing plural key") + .text.assert.isEqualTo("{count, plural,\nother {No plural}\n}") + + // migrates old existing values + getTranslation("en", "existing non plural key") + .text.assert.isEqualTo("{count, plural,\nother {I am not a plural!}\n}") + + // keeps non-plurals + getTranslation("en", "existing non plural key 2") + .text.assert.isEqualTo("I am not a plural!") + getTranslation("cs", "existing non plural key 2") + .text.assert.isEqualTo("Nejsem plurál!") + } + + private fun saveTestDataAndApplyImport() { + testData = ImportPluralizationTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.userAccount + projectSupplier = { testData.projectBuilder.self } + performProjectAuthPut("import/apply").andIsOk + } + + private fun getTranslation( + language: String, + key: String, + ): Translation { + return entityManager.createQuery( + """ + from Translation t + join t.key k + join t.language l + where k.name = :key and l.tag = :language and k.project.id = :projectId + """, + Translation::class.java, + ) + .setParameter("key", key) + .setParameter("projectId", testData.projectBuilder.self.id) + .setParameter("language", language).singleResult + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerResultTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerResultTest.kt index ac4b45f809..cbf903a5d3 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerResultTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/V2ImportControllerResultTest.kt @@ -95,12 +95,35 @@ class V2ImportControllerResultTest : AuthorizedControllerTest() { } @Test - fun `it return correct translation data`() { + fun `it return correct translation data (all)`() { val testData = ImportTestData() + testData.addPluralImport() testDataService.saveTestData(testData.root) - loginAsUser(testData.root.data.userAccounts[0].self.username) + performAuthGet( + "/v2/projects/${testData.project.id}" + + "/import/result/languages/${testData.importEnglish.id}/translations", + ).andIsOk + .andPrettyPrint.andAssertThatJson { + node("_embedded.translations") { + isArray.isNotEmpty.hasSize(6) + node("[1]") { + node("id").isNotNull + node("keyName").isEqualTo("plural key") + node("keyId").isNotNull + node("isPlural").isEqualTo(false) + node("existingKeyIsPlural").isEqualTo(true) + } + } + } + } + @Test + fun `it return correct translation data (only conflicts)`() { + val testData = ImportTestData() + testData.addPluralImport() + testDataService.saveTestData(testData.root) + loginAsUser(testData.root.data.userAccounts[0].self.username) performAuthGet( "/v2/projects/${testData.project.id}" + "/import/result/languages/${testData.importEnglish.id}/translations?onlyConflicts=true", @@ -116,6 +139,9 @@ class V2ImportControllerResultTest : AuthorizedControllerTest() { node("conflictId").isNotNull node("conflictText").isEqualTo("What a text") node("override").isEqualTo(false) + node("isPlural").isEqualTo(false) + node("existingKeyIsPlural").isEqualTo(false) + node("keyDescription").isEqualTo("This is a key") } } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerApplicationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerApplicationTest.kt new file mode 100644 index 0000000000..25112bd829 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerApplicationTest.kt @@ -0,0 +1,225 @@ +package io.tolgee.api.v2.controllers.v2ImportController.importSettings + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.key.Key +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.io.Resource +import org.springframework.test.web.servlet.ResultActions + +class ImportSettingsControllerApplicationTest : ProjectAuthControllerTest("/v2/projects/") { + private lateinit var testData: BaseTestData + + @Value("classpath:import/po/example.po") + lateinit var poFile: Resource + + @Value("classpath:import/android/strings_params_everywhere.xml") + lateinit var androidFile: Resource + + @Value("classpath:import/apple/params_everywhere_cs.xliff") + lateinit var appleXliffFile: Resource + + @BeforeEach + fun setup() { + testData = BaseTestData() + testData.projectBuilder.addGerman() + saveAndPrepare() + } + + @Test + @ProjectJWTAuthTestMethod + fun `updates placeholders for po file`() { + saveAndPrepare() + performImport(project.id, listOf("example.po" to poFile)) + assertTranslation( + "%d page read.", + "{0, plural,\none {Eine Seite gelesen wurde.}\nother {{0, number} Seiten gelesen wurden.}\n}", + ) + assertTranslation( + "Welcome back, %1${'$'}s! Your last visit was on %2${'$'}s", + "Willkommen zurück, {0}! Dein letzter Besuch war am {1}", + ) + + applySettings(overrideKeyDescriptions = false, convertPlaceholdersToIcu = false) + assertTranslation( + "%d page read.", + "{0, plural,\none {Eine Seite gelesen wurde.}\nother {%d Seiten gelesen wurden.}\n}", + ) + assertTranslation( + "Welcome back, %1${'$'}s! Your last visit was on %2${'$'}s", + "Willkommen zurück, %1${'$'}s! Dein letzter Besuch war am %2${'$'}s", + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `updates placeholders for android file`() { + saveAndPrepare() + performImport(project.id, listOf("strings_params_everywhere.xml" to androidFile)) + assertTranslation( + "dogs_count", + "{0, plural,\none {# dog {1}}\nother {# dogs {1}}\n}", + ) + assertTranslation( + "string_array[0]", + "First item {0, number}", + ) + assertTranslation( + "string_array[1]", + "Second item {0, number}", + ) + assertTranslation("with_params", "{0, number} {3} {2, number, .00} {3, number, scientific} %+d") + applySettings(overrideKeyDescriptions = false, convertPlaceholdersToIcu = false) + assertTranslation( + "dogs_count", + "{0, plural,\none {%d dog %s}\nother {%d dogs %s}\n}", + ) + assertTranslation( + "string_array[0]", + "First item %d", + ) + assertTranslation( + "string_array[1]", + "Second item %d", + ) + assertTranslation("with_params", "%d %4${'$'}s %.2f %e %+d") + } + + @Test + @ProjectJWTAuthTestMethod + fun `updates placeholders for apple xliff file`() { + saveAndPrepare() + performImport(project.id, listOf("params_everywhere.xliff" to appleXliffFile)) + assertTranslation( + "Hi %lld", + "Hi {0, number}", + ) + assertTranslation( + "Order %lld", + "{0, plural,\n" + + "zero {Order # Ticket}\n" + + "one {Order # Ticket}\n" + + "other {Order # Tickets}\n" + + "}", + ) + + applySettings(overrideKeyDescriptions = false, convertPlaceholdersToIcu = false) + + assertTranslation( + "Hi %lld", + "Hi %lld", + ) + assertTranslation( + "Order %lld", + "{0, plural,\n" + + "zero {Order %lld Ticket}\n" + + "one {Order %lld Ticket}\n" + + "other {Order %lld Tickets}\n" + + "}", + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `doesn't override descriptions when disabled`() { + val key = createKeyToOverrideDescriptionFor() + val oldDescription = key.keyMeta!!.description!! + saveAndPrepare() + performImportWithSettings(overrideKeyDescriptions = false) + assertKeyDescription(key, oldDescription) + } + + @Test + @ProjectJWTAuthTestMethod + fun `overrides descriptions when enabled`() { + val key = createKeyToOverrideDescriptionFor() + saveAndPrepare() + performImportWithSettings(overrideKeyDescriptions = true) + assertKeyDescription( + key, + "This is the text that should appear next to menu accelerators " + + "* that use the super key. If the text on this key isn't typically " + + "* translated on keyboards used for your language, don't translate " + + "* this.", + ) + } + + private fun assertKeyDescription( + key: Key, + description: String, + ) { + executeInNewTransaction { + val keyRefreshed = keyService.get(key.id) + keyRefreshed.keyMeta!!.description.assert.isEqualTo(description) + } + } + + private fun performImportWithSettings(overrideKeyDescriptions: Boolean) { + performImport(project.id, listOf("example.po" to poFile)) + applySettings(overrideKeyDescriptions = overrideKeyDescriptions, convertPlaceholdersToIcu = true) + performProjectAuthPut("import/apply?forceMode=OVERRIDE", null).andIsOk + } + + private fun createKeyToOverrideDescriptionFor(): Key { + val keyName = + "We connect developers and translators around the globe " + + "in Tolgee for a fantastic localization experience." + val key = + testData.projectBuilder.addKey(keyName).build { + addMeta { + description = "This is a description" + } + }.self + return key + } + + private fun applySettings( + overrideKeyDescriptions: Boolean, + convertPlaceholdersToIcu: Boolean, + ) { + performProjectAuthPut( + "import-settings", + mapOf( + "overrideKeyDescriptions" to overrideKeyDescriptions, + "convertPlaceholdersToIcu" to convertPlaceholdersToIcu, + ), + ).andIsOk + } + + private fun performImport( + projectId: Long, + files: List>?, + params: Map = mapOf(), + ): ResultActions { + loginAsAdminIfNotLogged() + return io.tolgee.util.performImport(mvc, projectId, files, params) + } + + private fun assertTranslation( + keyName: String, + translation: String, + ) { + executeInNewTransaction { + importService.find( + project.id, + userAccount!!.id, + )!! + .files.first() + .languages.first() + .translations.find { it.key.name == keyName }!! + .text.assert + .isEqualTo(translation) + } + } + + private fun saveAndPrepare() { + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.project } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerTest.kt new file mode 100644 index 0000000000..a88f86e93d --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ImportController/importSettings/ImportSettingsControllerTest.kt @@ -0,0 +1,60 @@ +package io.tolgee.api.v2.controllers.v2ImportController.importSettings + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.dataImport.ImportSettings +import io.tolgee.model.dataImport.ImportSettingsId +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class ImportSettingsControllerTest : ProjectAuthControllerTest("/v2/projects/") { + @Test + @ProjectJWTAuthTestMethod + fun `stores settings`() { + val testData = BaseTestData() + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.project } + performProjectAuthPut( + "import-settings", + mapOf( + "overrideKeyDescriptions" to true, + "convertPlaceholdersToIcu" to false, + ), + ).andIsOk.andAssertThatJson { + node("overrideKeyDescriptions").isBoolean.isTrue + node("convertPlaceholdersToIcu").isBoolean.isFalse + } + + executeInNewTransaction { + entityManager.getReference(ImportSettings::class.java, ImportSettingsId(userAccount!!.id, project.id)).apply { + this.overrideKeyDescriptions.assert.isTrue() + this.convertPlaceholdersToIcu.assert.isFalse() + } + } + } + + @Test + @ProjectJWTAuthTestMethod + fun `returns settings`() { + val testData = BaseTestData() + testData.projectBuilder.setImportSettings { + userAccount = testData.user + overrideKeyDescriptions = true + convertPlaceholdersToIcu = false + } + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.project } + + performProjectAuthGet( + "import-settings", + ).andIsOk.andAssertThatJson { + node("overrideKeyDescriptions").isBoolean.isTrue + node("convertPlaceholdersToIcu").isBoolean.isFalse + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexUpdateTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexEditTest.kt similarity index 85% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexUpdateTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexEditTest.kt index 70b32c12cd..0a30d1b55d 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexUpdateTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerComplexEditTest.kt @@ -1,6 +1,7 @@ package io.tolgee.api.v2.controllers.v2KeyController import io.tolgee.ProjectAuthControllerTest +import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.KeysTestData import io.tolgee.dtos.RelatedKeyDto import io.tolgee.dtos.request.KeyInScreenshotPositionDto @@ -8,6 +9,7 @@ import io.tolgee.dtos.request.key.ComplexEditKeyDto import io.tolgee.dtos.request.key.KeyScreenshotDto import io.tolgee.exceptions.FileStoreException import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andHasErrorMessage import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsForbidden import io.tolgee.fixtures.andIsOk @@ -31,11 +33,12 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.core.io.InputStreamSource +import org.springframework.test.web.servlet.ResultActions import java.math.BigDecimal @SpringBootTest @AutoConfigureMockMvc -class KeyControllerComplexUpdateTest : ProjectAuthControllerTest("/v2/projects/") { +class KeyControllerComplexEditTest : ProjectAuthControllerTest("/v2/projects/") { lateinit var testData: KeysTestData @Autowired @@ -427,4 +430,92 @@ class KeyControllerComplexUpdateTest : ProjectAuthControllerTest("/v2/projects/" ), ).andIsBadRequest } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + ], + ) + @Test + fun `updates isPlural`() { + performIsPluralModification().andIsOk.andAssertThatJson { + node("isPlural").isEqualTo(true) + } + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `checks for KEY_EDIT permissions when updating isPlural`() { + performIsPluralModification().andIsForbidden + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `checks permissions on custom values change`() { + performProjectAuthPut( + "keys/${testData.firstKey.id}/complex-update", + ComplexEditKeyDto( + name = testData.firstKey.name, + custom = mapOf("custom" to "value"), + ), + ).andIsForbidden + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + ], + ) + @Test + fun `stores custom values change`() { + performProjectAuthPut( + "keys/${testData.firstKey.id}/complex-update", + ComplexEditKeyDto( + name = testData.firstKey.name, + custom = mapOf("custom" to "value"), + ), + ).andIsOk + + executeInNewTransaction { + keyService.get(testData.firstKey.id).let { + assertThat(it.keyMeta!!.custom).isEqualTo(mapOf("custom" to "value")) + } + } + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + ], + ) + @Test + fun `validates custom values`() { + performProjectAuthPut( + "keys/${testData.firstKey.id}/complex-update", + ComplexEditKeyDto( + name = testData.firstKey.name, + custom = mapOf("custom" to (0..5000).joinToString("") { "a" }), + ), + ).andIsBadRequest.andHasErrorMessage(Message.CUSTOM_VALUES_JSON_TOO_LONG) + } + + private fun performIsPluralModification(): ResultActions { + val keyName = "super_key" + + return performProjectAuthPut( + "keys/${testData.keyWithReferences.id}/complex-update", + ComplexEditKeyDto( + name = keyName, + isPlural = true, + ), + ) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerInfoTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerInfoTest.kt index 9f27da3eef..d75413e315 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerInfoTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerInfoTest.kt @@ -52,6 +52,12 @@ class KeyControllerInfoTest : ProjectAuthControllerTest("/v2/projects/") { ).andIsOk.andAssertThatJson { node("_embedded.keys") { isArray.hasSize(22) + node("[0]") { + node("custom") { + isObject.hasSize(1) + node("key").isEqualTo("value") + } + } node("[20]") { node("namespace").isEqualTo("namespace-1") node("name").isEqualTo("key-1") diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerPluralizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerPluralizationTest.kt new file mode 100644 index 0000000000..0b94b08142 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerPluralizationTest.kt @@ -0,0 +1,372 @@ +package io.tolgee.api.v2.controllers.v2KeyController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.KeysTestData +import io.tolgee.dtos.request.key.ComplexEditKeyDto +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.isValidId +import io.tolgee.model.enums.Scope +import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert +import io.tolgee.util.generateImage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.core.io.InputStreamSource + +private const val RAW_PLURAL = "I have {dogsCount, plural, one {# dog} other {# dogs}}." +private const val NORMALIZED_PLURAL = + "{dogsCount, plural,\n" + + "one {I have # dog.}\n" + + "other {I have # dogs.}\n" + + "}" + +@SpringBootTest +@AutoConfigureMockMvc +class KeyControllerPluralizationTest : ProjectAuthControllerTest("/v2/projects/") { + lateinit var testData: KeysTestData + + val screenshotFile: InputStreamSource by lazy { + generateImage(2000, 3000) + } + + @BeforeEach + fun setup() { + testData = KeysTestData() + } + + private fun saveAndPrepare() { + testDataService.saveTestData(testData.root) + userAccount = testData.user + this.projectSupplier = { testData.project } + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `validates incoming plurals`() { + saveAndPrepare() + val keyName = "super_key" + + performProjectAuthPut( + "keys/${testData.keyWithReferences.id}/complex-update", + ComplexEditKeyDto( + name = keyName, + translations = mapOf("en" to "Not a plural"), + isPlural = true, + ), + ).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("invalid_plural_form") + node("params").isEqualTo(" [ [ \"Not a plural\" ] ]") + } + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `normalizes incoming plurals`() { + saveAndPrepare() + val keyName = "super_key" + + performProjectAuthPut( + "keys/${testData.keyWithReferences.id}/complex-update", + ComplexEditKeyDto( + name = keyName, + translations = mapOf("en" to RAW_PLURAL), + isPlural = true, + ), + ).andIsOk.andAssertThatJson { + node("isPlural").isBoolean.isTrue + node("translations.en.text").isString.isEqualTo(NORMALIZED_PLURAL) + } + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `converts existing translations`() { + val key = + testData.projectBuilder + .addKey { + name = "plural_test_key" + }.build { + addTranslation("en", "Oh") + addTranslation("de", RAW_PLURAL) + } + testData.projectBuilder.addCzech() + + saveAndPrepare() + + val keyName = "plural_test_key" + performProjectAuthPut( + "keys/${key.self.id}/complex-update", + ComplexEditKeyDto( + name = keyName, + translations = mapOf("cs" to RAW_PLURAL), + isPlural = true, + ), + ).andIsOk.andAssertThatJson { + node("isPlural").isBoolean.isTrue + node("translations.en.text").isString.isEqualTo("{value, plural,\nother {Oh}\n}") + node("translations.de.text").isString.isEqualTo(NORMALIZED_PLURAL) + node("translations.cs.text").isString.isEqualTo(NORMALIZED_PLURAL) + } + + keyService.get(key.self.id).isPlural.assert.isEqualTo(true) + } + + @ProjectApiKeyAuthTestMethod( + scopes = [ + Scope.KEYS_EDIT, + Scope.TRANSLATIONS_EDIT, + ], + ) + @Test + fun `change to argName works`() { + val key = + testData.projectBuilder + .addKey { + name = "plural_test_key" + isPlural = true + pluralArgName = "dogsCount" + }.build { + addTranslation("en", RAW_PLURAL) + addTranslation("de", RAW_PLURAL) + } + testData.projectBuilder.addCzech() + + saveAndPrepare() + + val keyName = "plural_test_key" + performProjectAuthPut( + "keys/${key.self.id}/complex-update", + ComplexEditKeyDto( + name = keyName, + isPlural = true, + pluralArgName = "catsCount", + ), + ).andIsOk.andAssertThatJson { + node("isPlural").isBoolean.isTrue + node("translations.de.text").isString + .isEqualTo(NORMALIZED_PLURAL.replace("dogsCount", "catsCount")) + } + + keyService.get(key.self.id).pluralArgName.assert.isEqualTo("catsCount") + } + + @ProjectJWTAuthTestMethod + @Test + fun `normalizes on create`() { + saveAndPrepare() + performProjectAuthPost( + "keys", + CreateKeyDto( + name = "new_key", + translations = mapOf("en" to RAW_PLURAL), + isPlural = true, + ), + ).andIsCreated.andAssertThatJson { + node("id").isValidId + node("name").isEqualTo("new_key") + node("isPlural").isBoolean.isTrue + node("translations.en.text").isString.isEqualTo(NORMALIZED_PLURAL) + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `validates on create`() { + saveAndPrepare() + performProjectAuthPost( + "keys", + CreateKeyDto( + name = "new_key", + translations = + mapOf( + "en" to "Not a plural", + ), + isPlural = true, + ), + ).andIsBadRequest + } + + @ProjectJWTAuthTestMethod + @Test + fun `respects pluralArgName on create`() { + saveAndPrepare() + performProjectAuthPost( + "keys", + CreateKeyDto( + name = "new_key", + pluralArgName = "dogsCount", + translations = + mapOf( + "en" to "{count, plural, one {# dog} other {# dogs}}", + ), + isPlural = true, + ), + ).andIsCreated.andAssertThatJson { + node("pluralArgName").isEqualTo("dogsCount") + node("translations.en.text").isString.isEqualTo("{dogsCount, plural,\none {# dog}\nother {# dogs}\n}") + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `correctly imports with non-resolvable endpoint`() { + testData.projectBuilder.addCzech() + saveAndPrepare() + performProjectAuthPost( + "keys/import", + mapOf( + "keys" to + listOf( + mapOf( + "name" to "plural_key", + "translations" to + mapOf( + "en" to "Hello! I have {count} dogs.", + "cs" to "Ahoj! Já mám {count, plural, one {jednoho psa} few {# psi} other {# psů}}", + ), + ), + mapOf( + "name" to "not_plural_key", + "translations" to + mapOf( + "en" to "Hello!", + "cs" to "Ahoj!", + ), + ), + ), + ), + ).andIsOk + + executeInNewTransaction { + val pluralKey = keyService.find(testData.project.id, "plural_key", null) + pluralKey!!.isPlural.assert.isTrue() + pluralKey.translations.find { it.language.tag == "en" }!! + .text.assert.isEqualTo("{count, plural,\nother {Hello! I have {count} dogs.}\n}") + pluralKey.translations.find { it.language.tag == "cs" }!! + .text.assert.isEqualTo( + "{count, plural,\n" + + "one {Ahoj! Já mám jednoho psa}\n" + + "few {Ahoj! Já mám # psi}\n" + + "other {Ahoj! Já mám # psů}\n" + + "}", + ) + + val notPluralKey = keyService.find(testData.project.id, "not_plural_key", null) + notPluralKey!!.isPlural.assert.isFalse() + notPluralKey.translations.find { it.language.tag == "en" }!!.text.assert.isEqualTo("Hello!") + notPluralKey.translations.find { it.language.tag == "cs" }!!.text.assert.isEqualTo("Ahoj!") + } + } + + @ProjectJWTAuthTestMethod + @Test + fun `correctly imports with resolvable endpoint`() { + testData.projectBuilder.addCzech() + testData.projectBuilder.addKey { + name = "existing_non_plural" + }.build { + addTranslation("en", "Hello!") + } + saveAndPrepare() + performProjectAuthPost( + "keys/import-resolvable", + mapOf( + "keys" to + listOf( + mapOf( + "name" to "plural_key", + "translations" to + mapOf( + "en" to + mapOf( + "text" to "Hello! I have {count} dogs.", + "resolution" to "NEW", + ), + "cs" to + mapOf( + "text" to "Ahoj! Já mám {count, plural, one {jednoho psa} few {# psi} other {# psů}}", + "resolution" to "NEW", + ), + ), + ), + mapOf( + "name" to "not_plural_key", + "translations" to + mapOf( + "en" to + mapOf( + "text" to "Hello!", + "resolution" to "NEW", + ), + "cs" to + mapOf( + "text" to "Ahoj!", + "resolution" to "NEW", + ), + ), + ), + mapOf( + "name" to "existing_non_plural", + "translations" to + mapOf( + "cs" to + mapOf( + "text" to "{hello, plural, one {# pes} other {# psů}}", + "resolution" to "NEW", + ), + ), + ), + ), + ), + ).andIsOk + + executeInNewTransaction { + val pluralKey = keyService.find(testData.project.id, "plural_key", null) + pluralKey!!.isPlural.assert.isTrue() + pluralKey.translations.find { it.language.tag == "en" }!! + .text.assert.isEqualTo("{count, plural,\nother {Hello! I have {count} dogs.}\n}") + pluralKey.translations.find { it.language.tag == "cs" }!! + .text.assert.isEqualTo( + "{count, plural,\n" + + "one {Ahoj! Já mám jednoho psa}\n" + + "few {Ahoj! Já mám # psi}\n" + + "other {Ahoj! Já mám # psů}\n" + + "}", + ) + + val notPluralKey = keyService.find(testData.project.id, "not_plural_key", null) + notPluralKey!!.isPlural.assert.isFalse() + notPluralKey.translations.find { it.language.tag == "en" }!!.text.assert.isEqualTo("Hello!") + notPluralKey.translations.find { it.language.tag == "cs" }!!.text.assert.isEqualTo("Ahoj!") + + val existingNonPluralKey = keyService.find(testData.project.id, "existing_non_plural", null) + existingNonPluralKey!!.isPlural.assert.isTrue() + existingNonPluralKey.translations.find { it.language.tag == "en" }!! + .text.assert.isEqualTo("{hello, plural,\nother {Hello!}\n}") + existingNonPluralKey.translations.find { it.language.tag == "cs" }!! + .text.assert.isEqualTo("{hello, plural,\none {# pes}\nother {# psů}\n}") + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt index 338b798750..02ea742685 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt @@ -65,6 +65,21 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } + @ProjectJWTAuthTestMethod + @Test + fun `returns single key`() { + saveTestDataAndPrepare() + val keyId = testData.keyWithReferences.id + performProjectAuthGet("keys/$keyId") + .andIsOk.andAssertThatJson { + node("id").isValidId + node("name").isEqualTo("key_with_referecnces") + node("namespace").isNull() + node("description").isNull() + node("custom").isObject.containsKeys("custom") + } + } + @ProjectJWTAuthTestMethod @Test fun `does not create key when not valid`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerCreateTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerCreateTest.kt index 96ad66f67f..89165d9779 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerCreateTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerCreateTest.kt @@ -2,7 +2,7 @@ package io.tolgee.api.v2.controllers.v2ProjectsController import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.dtos.request.LanguageRequest -import io.tolgee.dtos.request.project.CreateProjectDTO +import io.tolgee.dtos.request.project.CreateProjectRequest import io.tolgee.fixtures.AuthorizedRequestFactory import io.tolgee.fixtures.andAssertError import io.tolgee.fixtures.andAssertThatJson @@ -31,14 +31,14 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { "\uD83C\uDDEC\uD83C\uDDE7", ) - lateinit var createForLanguagesDto: CreateProjectDTO + lateinit var createForLanguagesDto: CreateProjectRequest @BeforeEach fun setup() { val base = dbPopulator.createBase("SomeProject", "user") userAccount = base.userAccount createForLanguagesDto = - CreateProjectDTO( + CreateProjectRequest( name = "What a project", organizationId = base.organization.id, languages = @@ -73,11 +73,13 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { val userAccount = dbPopulator.createUserIfNotExists("testuser") val organization = dbPopulator.createOrganization("Test Organization", userAccount) loginAsUser("testuser") - val request = CreateProjectDTO("aaa", listOf(languageDTO), organizationId = organization.id) + val request = + CreateProjectRequest("aaa", listOf(languageDTO), organizationId = organization.id, icuPlaceholders = true) performAuthPost("/v2/projects", request).andIsOk.andAssertThatJson { + node("icuPlaceholders").isBoolean.isTrue node("id").asNumber().satisfies { projectService.get(it.toLong()).let { - assertThat(it.organizationOwner?.id).isEqualTo(organization.id) + assertThat(it.organizationOwner.id).isEqualTo(organization.id) } } } @@ -86,7 +88,7 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { @Test fun testCreateValidationEmptyLanguages() { val request = - CreateProjectDTO( + CreateProjectRequest( "A name", listOf(), ) @@ -96,7 +98,7 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { @Test fun `validates languages`() { val request = - CreateProjectDTO( + CreateProjectRequest( "A name", listOf( LanguageRequest( @@ -115,7 +117,7 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { private fun testCreateCorrectRequest() { val organization = dbPopulator.createOrganizationIfNotExist("nice", userAccount = userAccount!!) - val request = CreateProjectDTO("aaa", listOf(languageDTO), organizationId = organization.id) + val request = CreateProjectRequest("aaa", listOf(languageDTO), organizationId = organization.id) mvc.perform( AuthorizedRequestFactory.loggedPost("/v2/projects") .contentType(MediaType.APPLICATION_JSON).content( @@ -137,14 +139,14 @@ class V2ProjectsControllerCreateTest : AuthorizedControllerTest() { } private fun testCreateValidationSizeShort() { - val request = CreateProjectDTO("aa", listOf(languageDTO)) + val request = CreateProjectRequest("aa", listOf(languageDTO)) val mvcResult = performAuthPost("/v2/projects", request).andIsBadRequest.andReturn() assertThat(mvcResult).error().isStandardValidation } private fun testCreateValidationSizeLong() { val request = - CreateProjectDTO( + CreateProjectRequest( "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...", listOf(languageDTO), ) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerEditTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerEditTest.kt index 389c82ecde..64da10fac2 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerEditTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerEditTest.kt @@ -1,6 +1,6 @@ package io.tolgee.api.v2.controllers.v2ProjectsController -import io.tolgee.dtos.request.project.EditProjectDTO +import io.tolgee.dtos.request.project.EditProjectRequest import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsBadRequest import io.tolgee.fixtures.andIsOk @@ -17,15 +17,17 @@ class V2ProjectsControllerEditTest : AuthorizedControllerTest() { fun `edits project`() { val base = dbPopulator.createBase("What a project") val content = - EditProjectDTO( + EditProjectRequest( name = "new name", baseLanguageId = base.project.languages.toList()[1].id, slug = "new-slug", + icuPlaceholders = true, ) performAuthPut("/v2/projects/${base.project.id}", content).andPrettyPrint.andIsOk.andAssertThatJson { node("name").isEqualTo(content.name) node("slug").isEqualTo(content.slug) node("baseLanguage.id").isEqualTo(content.baseLanguageId) + node("icuPlaceholders").isEqualTo(content.icuPlaceholders) } } @@ -33,7 +35,7 @@ class V2ProjectsControllerEditTest : AuthorizedControllerTest() { fun `validates project on edit`() { val base = dbPopulator.createBase("What a project") val content = - EditProjectDTO( + EditProjectRequest( name = "", baseLanguageId = base.project.languages.toList()[0].id, ) @@ -46,7 +48,7 @@ class V2ProjectsControllerEditTest : AuthorizedControllerTest() { fun `automatically chooses base language`() { val base = dbPopulator.createBase("What a project") val content = - EditProjectDTO( + EditProjectRequest( name = "test", ) performAuthPut("/v2/projects/${base.project.id}", content).andPrettyPrint.andIsOk.andAssertThatJson { diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerTest.kt index 8b059e1ad4..286978ce12 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ProjectsController/V2ProjectsControllerTest.kt @@ -40,6 +40,7 @@ open class V2ProjectsControllerTest : ProjectAuthControllerTest("/v2/projects/") it.node("[0].organizationOwner.name").isEqualTo("kim") it.node("[2].organizationOwner.name").isEqualTo("cool") it.node("[2].organizationOwner.slug").isEqualTo("cool") + it.node("[2].icuPlaceholders").isEqualTo(true) } } diff --git a/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt b/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt index 983224bee9..93fc711fbb 100644 --- a/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/cache/AbstractCacheTest.kt @@ -78,8 +78,8 @@ abstract class AbstractCacheTest : AbstractSpringTest() { sourceLanguageTag = "en", targetLanguageTag = "de", serviceInfo = MtServiceInfo(MtServiceType.GOOGLE, null), - isBatch = false, metadata = null, + isBatch = false, ) } diff --git a/backend/app/src/test/kotlin/io/tolgee/service/LanguageCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/LanguageCachingTest.kt index d73994c33c..6801026763 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/LanguageCachingTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/LanguageCachingTest.kt @@ -8,7 +8,7 @@ import io.tolgee.AbstractSpringTest import io.tolgee.constants.Caches import io.tolgee.development.testDataBuilder.data.BaseTestData import io.tolgee.dtos.request.LanguageRequest -import io.tolgee.dtos.request.project.EditProjectDTO +import io.tolgee.dtos.request.project.EditProjectRequest import io.tolgee.model.Language import io.tolgee.repository.LanguageRepository import io.tolgee.testing.assert @@ -92,7 +92,7 @@ class LanguageCachingTest : AbstractSpringTest() { executeInNewTransaction { projectService.editProject( testData.project.id, - EditProjectDTO( + EditProjectRequest( name = "test", baseLanguageId = germanLanguage.id, ), diff --git a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt index b1301113c8..de67b23945 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/dataImport/StoredDataImporterTest.kt @@ -1,6 +1,7 @@ package io.tolgee.service.dataImport import io.tolgee.AbstractSpringTest +import io.tolgee.api.IImportSettings import io.tolgee.development.testDataBuilder.data.dataImport.ImportTestData import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.BadRequestException @@ -20,6 +21,12 @@ class StoredDataImporterTest : AbstractSpringTest() { lateinit var storedDataImporter: StoredDataImporter lateinit var importTestData: ImportTestData + val defaultImportSettings = + object : IImportSettings { + override var convertPlaceholdersToIcu: Boolean = true + override var overrideKeyDescriptions: Boolean = false + } + @BeforeEach fun setup() { importTestData = ImportTestData() @@ -27,6 +34,7 @@ class StoredDataImporterTest : AbstractSpringTest() { StoredDataImporter( applicationContext, importTestData.import, + importSettings = defaultImportSettings, ) } @@ -92,6 +100,7 @@ class StoredDataImporterTest : AbstractSpringTest() { applicationContext, importTestData.import, ForceMode.OVERRIDE, + importSettings = defaultImportSettings, ) testDataService.saveTestData(importTestData.root) login() @@ -112,6 +121,7 @@ class StoredDataImporterTest : AbstractSpringTest() { applicationContext, importTestData.import, ForceMode.OVERRIDE, + importSettings = defaultImportSettings, ) login() storedDataImporter.doImport() @@ -141,6 +151,7 @@ class StoredDataImporterTest : AbstractSpringTest() { applicationContext, importTestData.import, ForceMode.KEEP, + importSettings = defaultImportSettings, ) testDataService.saveTestData(importTestData.root) login() diff --git a/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt index 51146862f1..aee08daa93 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/service/queryBuilders/CursorUtilUnitTest.kt @@ -20,6 +20,8 @@ class CursorUtilUnitTest { KeyWithTranslationsView( keyId = 1, keyName = "Super key", + keyIsPlural = false, + keyPluralArgName = null, keyNamespaceId = null, keyNamespace = null, keyDescription = "Super key description", diff --git a/backend/app/src/test/kotlin/io/tolgee/util/performImport.kt b/backend/app/src/test/kotlin/io/tolgee/util/performImport.kt new file mode 100644 index 0000000000..0876dc7915 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/util/performImport.kt @@ -0,0 +1,36 @@ +package io.tolgee.util + +import io.tolgee.fixtures.AuthorizedRequestFactory +import org.springframework.core.io.Resource +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders + +fun performImport( + mvc: MockMvc, + projectId: Long, + files: List>?, + params: Map = mapOf(), +): ResultActions { + val builder = + MockMvcRequestBuilders + .multipart("/v2/projects/$projectId/import?${mapToQueryString(params)}") + + files?.forEach { + builder.file( + MockMultipartFile( + "files", + it.first, + "application/zip", + it.second.file.readBytes(), + ), + ) + } + + return mvc.perform(AuthorizedRequestFactory.addToken(builder)) +} + +private fun mapToQueryString(map: Map): String { + return map.entries.joinToString("&") { "${it.key}=${it.value}" } +} diff --git a/backend/app/src/test/resources/application.yaml b/backend/app/src/test/resources/application.yaml index 83e86fc219..e7970939a9 100644 --- a/backend/app/src/test/resources/application.yaml +++ b/backend/app/src/test/resources/application.yaml @@ -90,5 +90,5 @@ logging: io.tolgee: DEBUG io.tolgee.component.CurrentDateProvider: DEBUG io.tolgee.component.reporting.BusinessEventPublisher: DEBUG - io.tolgee.component.reporting.ReportingService: DEBUG - org.springframework.boot.autoconfigure.logging: DEBUG + io.tolgee.ExceptionHandlers: DEBUG + io.tolgee.component.reporting.ReportingService: DEBUG \ No newline at end of file diff --git a/backend/app/src/test/resources/import/almost_simple.json b/backend/app/src/test/resources/import/almost_simple.json new file mode 100644 index 0000000000..411daa7bb9 --- /dev/null +++ b/backend/app/src/test/resources/import/almost_simple.json @@ -0,0 +1,3 @@ +{ + "test": "no test!" +} diff --git a/backend/app/src/test/resources/import/android/strings_params_everywhere.xml b/backend/app/src/test/resources/import/android/strings_params_everywhere.xml new file mode 100644 index 0000000000..c470a65d76 --- /dev/null +++ b/backend/app/src/test/resources/import/android/strings_params_everywhere.xml @@ -0,0 +1,13 @@ + + + %d dog %s + %d dogs %s + + + First item %d + Second item %d + + + %d %4$s %.2f %e %+d + + diff --git a/backend/app/src/test/resources/import/apple/params_everywhere_cs.xliff b/backend/app/src/test/resources/import/apple/params_everywhere_cs.xliff new file mode 100644 index 0000000000..9a85126ba5 --- /dev/null +++ b/backend/app/src/test/resources/import/apple/params_everywhere_cs.xliff @@ -0,0 +1,76 @@ + + + +
+ +
+ + + Dogs %lld + The count of dogs in the app + + + Order %lld + No comment provided by engineer. + + + Hi %lld + + +
+ +
+ +
+ + + %#@dog@ + + + + %lld dogs here %@ + + + + %lld dogs here %@ + + + + One dog is here %@! + + + + %lld dogs here %@ + + + + No dogs here %@! + + + + %#@Ticket@ + + + + Order %lld Tickets + + + + Order %lld Tickets + + + + Order %lld Ticket + + + + Order %lld Tickets + + + + Order %lld Ticket + + + +
+
diff --git a/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.strings b/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.strings new file mode 100644 index 0000000000..d1e1b89dae --- /dev/null +++ b/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.strings @@ -0,0 +1,3 @@ +/* No comment provided by engineer. */ +"Order %lld" = "Order %lld"; + diff --git a/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.stringsdict b/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.stringsdict new file mode 100644 index 0000000000..2eda3b4f82 --- /dev/null +++ b/backend/app/src/test/resources/import/apple/stringsStringsDictConflict/Localizable.stringsdict @@ -0,0 +1,24 @@ + + + + + Order %lld + + NSStringLocalizedFormatKey + %#@Ticket@ + Ticket + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + Order %lld Ticket + one + Order %lld Ticket + other + Order %lld Tickets + + + + diff --git a/backend/data/build.gradle b/backend/data/build.gradle index e2781ad5e2..53f9e07128 100644 --- a/backend/data/build.gradle +++ b/backend/data/build.gradle @@ -167,6 +167,7 @@ dependencies { implementation libs.micrometerPrometheus implementation 'org.dom4j:dom4j:2.1.4' implementation libs.jacksonKotlin + implementation("org.apache.commons:commons-configuration2:2.9.0") /** * Google translation API diff --git a/backend/data/src/main/kotlin/io/tolgee/api/IImportSettings.kt b/backend/data/src/main/kotlin/io/tolgee/api/IImportSettings.kt new file mode 100644 index 0000000000..7331566059 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/IImportSettings.kt @@ -0,0 +1,27 @@ +package io.tolgee.api + +import io.swagger.v3.oas.annotations.media.Schema + +interface IImportSettings { + @get:Schema( + description = "If true, key descriptions will be overridden by the import", + ) + var overrideKeyDescriptions: Boolean + + @get:Schema( + description = "If true, placeholders from other formats will be converted to ICU when possible", + ) + var convertPlaceholdersToIcu: Boolean + + fun assignFrom(other: IImportSettings) { + this.overrideKeyDescriptions = other.overrideKeyDescriptions + this.convertPlaceholdersToIcu = other.convertPlaceholdersToIcu + } + + fun clone(): IImportSettings { + return object : IImportSettings { + override var overrideKeyDescriptions: Boolean = this@IImportSettings.overrideKeyDescriptions + override var convertPlaceholdersToIcu: Boolean = this@IImportSettings.convertPlaceholdersToIcu + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/ISimpleProject.kt b/backend/data/src/main/kotlin/io/tolgee/api/ISimpleProject.kt new file mode 100644 index 0000000000..8226650928 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/ISimpleProject.kt @@ -0,0 +1,14 @@ +package io.tolgee.api + +import io.swagger.v3.oas.annotations.media.Schema + +interface ISimpleProject { + val id: Long + val name: String + val description: String? + val slug: String? + + @get:Schema(description = "Whether to disable ICU placeholder visualization in the editor and it's support.") + val icuPlaceholders: Boolean + val avatarHash: String? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/KeyCustomValuesValidator.kt b/backend/data/src/main/kotlin/io/tolgee/component/KeyCustomValuesValidator.kt new file mode 100644 index 0000000000..7f9de36c3a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/KeyCustomValuesValidator.kt @@ -0,0 +1,34 @@ +package io.tolgee.component + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.constants.Message +import io.tolgee.exceptions.BadRequestException +import org.springframework.stereotype.Component + +@Component +class KeyCustomValuesValidator( + val objectMapper: ObjectMapper, +) { + fun validate(customData: Map) { + validate(objectMapper.writeValueAsString(customData)) + } + + fun validate(customDataJsonString: String?) { + if (customDataJsonString.isNullOrBlank()) { + return + } + + if (customDataJsonString.length > 5000) { + throw BadRequestException(Message.CUSTOM_VALUES_JSON_TOO_LONG) + } + } + + fun isValid(customData: Map): Boolean { + return try { + validate(customData) + true + } catch (e: BadRequestException) { + false + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/LanguageTagConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/LanguageTagConvertor.kt index 9f20fdc0ba..533cdf9952 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/LanguageTagConvertor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/LanguageTagConvertor.kt @@ -5,31 +5,39 @@ object LanguageTagConvertor { fun findSuitableTag( suitableTags: Array, - desiredLanguage: String, + desiredTag: String, ): String? { - if (suitableTags.contains(desiredLanguage)) { - return desiredLanguage - } - // in Tolgee platform Traditional Chinese is zh-Hant, but AWS translate has is as zh-TW - if (desiredLanguage === "zh-Hant" && suitableTags.contains("zh-TW")) { + if (desiredTag === "zh-Hant" && suitableTags.contains("zh-TW")) { return "zh-TW" } - var desired = desiredLanguage + return findSuitableTag(desiredTag) { newTag -> + suitableTags.contains(newTag) + } + } + + fun findSuitableTag( + desiredTag: String, + validateFn: (newTag: String) -> Boolean, + ): String? { + if (validateFn(desiredTag)) { + return desiredTag + } + + var newTag = desiredTag var iterations = 0 - while (desired != "") { - desired = desired.replace(Regex("[-_]?[a-zA-Z0-9]+$"), "") - if (suitableTags.contains(desired)) { - return desired + while (newTag != "") { + newTag = newTag.replace(Regex("[-_]?[a-zA-Z0-9]+$"), "") + if (validateFn(newTag)) { + return newTag } if (iterations++ >= MAX_ITERATIONS) { break } } - return null } } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/MtServiceManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/MtServiceManager.kt index 9f7b720b35..246177a887 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/MtServiceManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/MtServiceManager.kt @@ -77,6 +77,8 @@ class MtServiceManager( params.metadata, params.serviceInfo.formality, params.isBatch, + pluralFormExamples = params.pluralFormExamples, + pluralForms = params.pluralForms, ), ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslateResult.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslateResult.kt index dc46fa1ce5..708642bc3b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslateResult.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslateResult.kt @@ -5,9 +5,10 @@ import java.io.Serializable data class TranslateResult( var translatedText: String?, - var contextDescription: String?, + var contextDescription: String? = null, var actualPrice: Int = 0, val usedService: MtServiceType? = null, - val baseBlank: Boolean, + val baseBlank: Boolean = false, val exception: Exception? = null, + val translatedPluralForms: Map? = null, ) : Serializable diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslationParams.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslationParams.kt index 74e4939572..47321c72e0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslationParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/TranslationParams.kt @@ -13,9 +13,13 @@ data class TranslationParams( val serviceInfo: MtServiceInfo, val metadata: Metadata?, val isBatch: Boolean, + var pluralForms: Map? = null, + val pluralFormExamples: Map? = null, ) { val cacheKey: String get() = jacksonObjectMapper() - .writeValueAsString(listOf(text, sourceLanguageTag, targetLanguageTag, serviceInfo, metadata)) + .writeValueAsString( + listOf(text, textRaw, pluralForms, sourceLanguageTag, targetLanguageTag, serviceInfo, metadata), + ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/ProviderTranslateParams.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/ProviderTranslateParams.kt index 72351c9457..df2c521810 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/ProviderTranslateParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/ProviderTranslateParams.kt @@ -15,4 +15,12 @@ data class ProviderTranslateParams( * Whether translation is executed as a part of batch translation task */ val isBatch: Boolean, + /** + * Only for translators supporting plurals + */ + val pluralForms: Map? = null, + /** + * Only for translators supporting plurals + */ + val pluralFormExamples: Map? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslateParams.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslateParams.kt index 5ef6a01840..a62663925a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslateParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslateParams.kt @@ -11,4 +11,6 @@ class TolgeeTranslateParams( val metadata: Metadata?, val formality: Formality?, val isBatch: Boolean, + val pluralForms: Map? = null, + val pluralFormExamples: Map? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslationProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslationProvider.kt index 5847e03062..a5d01bc7a2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslationProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/machineTranslation/providers/tolgee/TolgeeTranslationProvider.kt @@ -36,6 +36,8 @@ class TolgeeTranslationProvider( params.metadataOrThrow(), params.formality, params.isBatch, + pluralForms = params.pluralForms, + pluralFormExamples = params.pluralFormExamples, ), ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/CacheProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/CacheProperties.kt index 129d3f97db..6a2b9b5dcb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/CacheProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/CacheProperties.kt @@ -43,7 +43,7 @@ class CacheProperties( ) var caffeineMaxSize: Long = -1, @DocProperty( - "Whether to clean the cache on Tolgee startup", + description = "Whether to clean the cache on Tolgee startup", ) var cleanOnStartup: Boolean = true, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/PostgresAutostartProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/PostgresAutostartProperties.kt index 2579de5ae8..a5bb1f132f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/PostgresAutostartProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/PostgresAutostartProperties.kt @@ -63,7 +63,7 @@ class PostgresAutostartProperties { @DocProperty( description = - "When true, Tolgee will stop the Postgres container on Tolgee shutdown." + + "When true, Tolgee will stop the Postgres container on Tolgee shutdown. " + "This setting is applicable only for `DOCKER` mode.", ) var stop: Boolean = true diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/machineTranslation/TolgeeMachineTranslationProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/machineTranslation/TolgeeMachineTranslationProperties.kt index bdc4ffcd47..9ca30bd99d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/machineTranslation/TolgeeMachineTranslationProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/machineTranslation/TolgeeMachineTranslationProperties.kt @@ -1,10 +1,12 @@ package io.tolgee.configuration.tolgee.machineTranslation +import io.tolgee.configuration.annotations.DocProperty import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "tolgee.machine-translation.tolgee") open class TolgeeMachineTranslationProperties( override var defaultEnabled: Boolean = true, override var defaultPrimary: Boolean = true, + @DocProperty(hidden = true) var url: String? = "https://app.tolgee.io", ) : MachineTranslationServiceProperties diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 6684d34c5e..3e2ee70f5e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -93,7 +93,6 @@ enum class Message { CANNOT_FIND_BASE_LANGUAGE, BASE_LANGUAGE_NOT_FOUND, NO_EXPORTED_RESULT, - MULTIPLE_FILES_MUST_BE_ZIPPED, CANNOT_SET_YOUR_OWN_ROLE, ONLY_TRANSLATE_REVIEW_OR_VIEW_PERMISSION_ACCEPTS_VIEW_LANGUAGES, OAUTH2_TOKEN_URL_NOT_SET, @@ -194,6 +193,15 @@ enum class Message { CANNOT_MODIFY_PLAN_FREE_STATUS, KEY_ID_NOT_PROVIDED, FREE_SELF_HOSTED_SEAT_LIMIT_EXCEEDED, + ADVANCED_PARAMS_NOT_SUPPORTED, + PLURAL_FORMS_NOT_FOUND_FOR_LANGUAGE, + NESTED_PLURALS_NOT_SUPPORTED, + MESSAGE_IS_NOT_PLURAL, + CONTENT_OUTSIDE_PLURAL_FORMS, + INVALID_PLURAL_FORM, + MULTIPLE_PLURALS_NOT_SUPPORTED, + CUSTOM_VALUES_JSON_TOO_LONG, + UNSUPPORTED_PO_MESSAGE_FORMAT, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/MtServiceType.kt b/backend/data/src/main/kotlin/io/tolgee/constants/MtServiceType.kt index 6492c7bb4e..eadf62da20 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/MtServiceType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/MtServiceType.kt @@ -19,6 +19,7 @@ enum class MtServiceType( val propertyClass: Class, val providerClass: Class, val usesMetadata: Boolean = false, + val supportsPlurals: Boolean = false, val order: Int = 0, ) { GOOGLE( @@ -51,5 +52,6 @@ enum class MtServiceType( providerClass = TolgeeTranslationProvider::class.java, usesMetadata = true, order = -1, + supportsPlurals = true, ), } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 3892b314f6..9e227c07d0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -211,6 +211,14 @@ class TestDataService( saveContentDeliveryConfigs(builder) saveWebhookConfigs(builder) saveAutomations(builder) + saveImportSettings(builder) + } + + private fun saveImportSettings(builder: ProjectBuilder) { + builder.data.importSettings?.let { + entityManager.merge(it.userAccount) + entityManager.persist(it) + } } private fun saveWebhookConfigs(builder: ProjectBuilder) { diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt index 55b67b1f76..6ee75dd180 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt @@ -12,6 +12,7 @@ import io.tolgee.model.automations.Automation import io.tolgee.model.contentDelivery.ContentDeliveryConfig import io.tolgee.model.contentDelivery.ContentStorage import io.tolgee.model.dataImport.Import +import io.tolgee.model.dataImport.ImportSettings import io.tolgee.model.key.Key import io.tolgee.model.key.Namespace import io.tolgee.model.key.screenshotReference.KeyScreenshotReference @@ -54,6 +55,7 @@ class ProjectBuilder( var contentStorages = mutableListOf() var contentDeliveryConfigs = mutableListOf() var webhookConfigs = mutableListOf() + var importSettings: ImportSettings? = null } var data = DATA() @@ -173,5 +175,16 @@ class ProjectBuilder( fun addWebhookConfig(ft: FT) = addOperation(data.webhookConfigs, ft) + fun setImportSettings(ft: FT) { + data.importSettings = ImportSettings(this.self).apply(ft) + } + val onlyUser get() = this.self.organizationOwner.memberRoles.singleOrNull()?.user + + fun getTranslation( + key: Key, + languageTag: String, + ): Translation? { + return this.data.translations.find { it.self.key == key && it.self.language.tag == languageTag }?.self + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysInfoTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysInfoTestData.kt index 2c8a6d42ed..c9ca4d0dc4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysInfoTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysInfoTestData.kt @@ -32,6 +32,9 @@ class KeysInfoTestData : BaseTestData() { addKey("key-$it") { setDescription("description") addTranslation("de", "existing translation") + addMeta { + custom = mutableMapOf("key" to "value") + } } addKey("ns", "key-$it") { setDescription("description") diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysTestData.kt index a1219869a4..5c0d4926ad 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeysTestData.kt @@ -101,6 +101,7 @@ class KeysTestData { line = 20 path = "./code/exist.extension" } + custom = mutableMapOf("custom" to "value") } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionTestData.kt index 9d2d8ccdd6..dcfb3be5d6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SuggestionTestData.kt @@ -234,4 +234,38 @@ class SuggestionTestData : BaseTestData() { germanLanguage.aiTranslatorPromptDescription = "This is a description for AI translator" project.aiTranslatorPromptDescription = "This is a description for AI translator" } + + fun addPluralKeys(): PluralKeys { + return PluralKeys( + addPluralKey("true plural", true), + addPluralKey("same true plural", true), + addPluralKey("false plural", false), + addPluralKey("same false plural", false), + ) + } + + data class PluralKeys( + val truePlural: Key, + val sameTruePlural: Key, + val falsePlural: Key, + val sameFalsePlural: Key, + ) + + private fun addPluralKey( + name: String, + isPlural: Boolean, + ): Key { + val isNotPluralString = if (isPlural) "" else "not plural" + return projectBuilder.addKey(name) { + this.self.isPlural = isPlural + addTranslation { + language = englishLanguage + text = "{value, plural, one {# dog} other {# dogs}}$isNotPluralString" + } + addTranslation { + language = germanLanguage + text = "{value, plural, one {# Hund} other {# Hunde}}$isNotPluralString" + } + }.self + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationsTestData.kt index 8da43c62f5..dd0f650a1b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/TranslationsTestData.kt @@ -392,6 +392,21 @@ class TranslationsTestData { } } + fun addPlural() { + projectBuilder.run { + addKey { + name = "i am plural" + isPlural = true + pluralArgName = "count" + }.build { + addTranslation { + text = "{count, plural, one {I am one} other {I am other}}" + language = englishLanguage + } + } + } + } + fun addCommentStatesData() { projectBuilder.run { addKey { @@ -425,4 +440,11 @@ class TranslationsTestData { } } } + + fun addPluralKey(): Key { + return projectBuilder.addKey { + name = "plural_key" + isPlural = true + }.self + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportCleanTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportCleanTestData.kt index 2dbb0520c2..44e3b514c1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportCleanTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportCleanTestData.kt @@ -1,11 +1,14 @@ package io.tolgee.development.testDataBuilder.data.dataImport import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Language import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType class ImportCleanTestData { + lateinit var french: Language + lateinit var english: Language var project: Project var userAccount: UserAccount val projectBuilder get() = root.data.projects[0] @@ -29,12 +32,12 @@ class ImportCleanTestData { addKey { name = "key1" }.self - val english = + english = addLanguage { name = "English" tag = "en" }.self - val french = + french = addLanguage { name = "French" tag = "fr" diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportPluralizationTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportPluralizationTestData.kt new file mode 100644 index 0000000000..35f42fa888 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportPluralizationTestData.kt @@ -0,0 +1,110 @@ +package io.tolgee.development.testDataBuilder.data.dataImport + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.UserAccount +import io.tolgee.model.dataImport.Import +import io.tolgee.model.enums.ProjectPermissionType + +class ImportPluralizationTestData { + var userAccount: UserAccount + + val root: TestDataBuilder = + TestDataBuilder().apply { + userAccount = + addUserAccount { + username = "franta" + name = "Frantisek Dobrota" + }.self + } + + lateinit var import: Import + + val projectBuilder = + root.addProject { name = "test" }.build project@{ + addPermission { + project = this@project.self + user = this@ImportPluralizationTestData.userAccount + type = ProjectPermissionType.MANAGE + } + + val english = addEnglish() + val czech = addCzech() + + addKey { + name = "existing plural key" + isPlural = true + pluralArgName = "count" + }.build { + addTranslation("en", "{count, plural, one {one} other {other}}") + } + + addKey { + name = "existing non plural key" + isPlural = false + }.build { + addTranslation("en", "I am not a plural!") + } + + addKey { + name = "existing non plural key 2" + isPlural = false + }.build { + addTranslation("en", "I am not a plural!") + } + + val importBuilder = + addImport { + author = userAccount + }.build { + addImportFile { + name = "multilang.json" + }.build { + val importEnglish = + addImportLanguage { + name = "en" + existingLanguage = english.self + }.self + + val importCzech = + addImportLanguage { + name = "cs" + existingLanguage = czech.self + } + + addImportTranslation { + this.language = importCzech.self + this.key = + addImportKey { + name = "existing plural key" + }.self + this.conflict = null + this.text = "No plural" + }.self + + addImportTranslation { + this.language = importCzech.self + this.key = + addImportKey { + name = "existing non plural key" + pluralArgName = "count" + }.self + this.conflict = null + isPlural = true + this.text = "{count, plural, one {one} other {other}}" + }.self + + addImportTranslation { + this.language = importCzech.self + this.key = + addImportKey { + name = "existing non plural key 2" + }.self + this.conflict = null + isPlural = false + this.text = "Nejsem plurál!" + }.self + } + } + import = importBuilder.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt index 99557b8052..d448c70c5b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/dataImport/ImportTestData.kt @@ -139,6 +139,10 @@ class ImportTestData { val addedKey = addImportKey { name = "what a key" + }.build { + addMeta { + description = "This is a key" + } } addImportKey { name = "what a nice key" @@ -406,6 +410,27 @@ class ImportTestData { return AddFilesWithNamespacesResult(importFrenchInNs!!) } + fun addPluralImport() { + this.projectBuilder.build { + addKey { + name = "plural key" + isPlural = true + pluralArgName = "count" + } + importBuilder.data.importFiles[0].build { + val key = + addImportKey { + name = "plural key" + } + addImportTranslation { + text = "Hey!" + this.key = key.self + language = importEnglish + } + } + } + } + data class AddFilesWithNamespacesResult( val importFrenchInNs: ImportLanguage, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/IExportParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/IExportParams.kt index b35b26256e..65a4479e29 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/IExportParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/IExportParams.kt @@ -3,7 +3,8 @@ package io.tolgee.dtos import com.fasterxml.jackson.annotation.JsonIgnore import io.swagger.v3.oas.annotations.Hidden import io.swagger.v3.oas.annotations.media.Schema -import io.tolgee.dtos.request.export.ExportFormat +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat import io.tolgee.model.enums.TranslationState interface IExportParams { @@ -30,6 +31,14 @@ When null, resulting file won't be structured. ) var structureDelimiter: Char? + @get:Schema( + description = """ + If true, for structured formats (like JSON) arrays are supported. + e.g. Key hello[0] will be exported as {"hello": ["..."]} + """, + ) + var supportArrays: Boolean + @get:Schema( description = """Filter key IDs to be contained in export""", ) @@ -65,6 +74,14 @@ When null, resulting file won't be structured. val shouldContainUntranslated: Boolean get() = this.filterState?.contains(TranslationState.UNTRANSLATED) != false + @get:Schema( + description = """Message format to be used for export. (applicable for .po) + +e.g. PHP_PO: Hello %s, PYTHON_PO: Hello %(name)s + """, + ) + var messageFormat: ExportMessageFormat? + fun copyPropsFrom(other: IExportParams) { this.languages = other.languages this.format = other.format @@ -75,5 +92,7 @@ When null, resulting file won't be structured. this.filterKeyPrefix = other.filterKeyPrefix this.filterState = other.filterState this.filterNamespace = other.filterNamespace + this.messageFormat = other.messageFormat + this.supportArrays = other.supportArrays } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt index 4855f2e6d2..ae9d65fc26 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/ProjectDto.kt @@ -1,16 +1,19 @@ package io.tolgee.dtos.cacheable +import io.tolgee.api.ISimpleProject import io.tolgee.model.Project import java.io.Serializable data class ProjectDto( - val name: String, - val description: String?, - val slug: String?, - val id: Long, + override val name: String, + override val description: String?, + override val slug: String?, + override val id: Long, val organizationOwnerId: Long, var aiTranslatorPromptDescription: String?, -) : Serializable { + override var avatarHash: String? = null, + override var icuPlaceholders: Boolean, +) : Serializable, ISimpleProject { companion object { fun fromEntity(entity: Project) = ProjectDto( @@ -20,6 +23,8 @@ data class ProjectDto( id = entity.id, organizationOwnerId = entity.organizationOwner.id, aiTranslatorPromptDescription = entity.aiTranslatorPromptDescription, + avatarHash = entity.avatarHash, + icuPlaceholders = entity.icuPlaceholders, ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportAddFilesParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportAddFilesParams.kt index 2785ee5c7e..40d1ccc977 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportAddFilesParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportAddFilesParams.kt @@ -10,4 +10,11 @@ class ImportAddFilesParams( ) var structureDelimiter: Char? = '.', var storeFilesToFileStorage: Boolean = true, + @field:Parameter( + description = + "If true, for structured formats (like JSON) arrays are supported. " + + "e.g. Array object like {\"hello\": [\"item1\", \"item2\"]} will be imported as keys " + + "hello[0] = \"item1\" and hello[1] = \"item2\".", + ) + var supportArrays: Boolean = true, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportFileDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportFileDto.kt index 76e3b8e6db..1bc6af684a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportFileDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/dataImport/ImportFileDto.kt @@ -1,6 +1,9 @@ package io.tolgee.dtos.dataImport data class ImportFileDto( + /** + * In case of zip file, this is the whole path + */ val name: String = "", - val data: ByteArray, + var data: ByteArray, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/KeyView.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/KeyView.kt index 8c190e5b32..a07dde539c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/KeyView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/queryResults/KeyView.kt @@ -5,4 +5,5 @@ data class KeyView( val name: String, val namespace: String?, val description: String?, + val custom: Any?, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt index 706133b022..0622a9c455 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt @@ -2,7 +2,8 @@ package io.tolgee.dtos.request import io.swagger.v3.oas.annotations.media.Schema import io.tolgee.dtos.IExportParams -import io.tolgee.dtos.request.export.ExportFormat +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat import io.tolgee.model.enums.TranslationState import jakarta.validation.constraints.NotBlank @@ -25,7 +26,7 @@ class ContentDeliveryConfigRequest : IExportParams { override var languages: Set? = null override var format: ExportFormat = ExportFormat.JSON override var structureDelimiter: Char? = '.' - + override var supportArrays: Boolean = false override var filterKeyId: List? = null override var filterKeyIdNot: List? = null @@ -41,4 +42,5 @@ class ContentDeliveryConfigRequest : IExportParams { ) override var filterNamespace: List? = null + override var messageFormat: ExportMessageFormat? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/SuggestRequestDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/SuggestRequestDto.kt index 1db5384581..66c8def104 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/SuggestRequestDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/SuggestRequestDto.kt @@ -9,6 +9,8 @@ data class SuggestRequestDto( var targetLanguageId: Long = 0, @Schema(description = "Text value of base translation. Useful, when base translation is not stored yet.") var baseText: String? = null, + @Schema(description = "Whether base text is plural. This value is ignored if baseText is null.") + var isPlural: Boolean? = false, @Schema(description = "List of services to use. If null, then all enabled services are used.") var services: Set? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/dataImport/ImportSettingsRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/dataImport/ImportSettingsRequest.kt new file mode 100644 index 0000000000..e86c7daf9a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/dataImport/ImportSettingsRequest.kt @@ -0,0 +1,11 @@ +package io.tolgee.dtos.request.dataImport + +import io.tolgee.api.IImportSettings +import jakarta.validation.constraints.NotNull + +class ImportSettingsRequest( + @NotNull + override var overrideKeyDescriptions: Boolean, + @NotNull + override var convertPlaceholdersToIcu: Boolean, +) : IImportSettings diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportFormat.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportFormat.kt deleted file mode 100644 index 61d8ca7b08..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportFormat.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.tolgee.dtos.request.export - -enum class ExportFormat(val extension: String, val mediaType: String) { - JSON("json", "application/json"), - XLIFF("xlf", "application/x-xliff+xml"), -} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportParams.kt index 3d84b4c7d1..30b7967401 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/export/ExportParams.kt @@ -2,6 +2,8 @@ package io.tolgee.dtos.request.export import io.swagger.v3.oas.annotations.Parameter import io.tolgee.dtos.IExportParams +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat import io.tolgee.model.enums.TranslationState data class ExportParams( @@ -60,4 +62,13 @@ This is possible only when single language is exported. Otherwise it returns "40 """, ) var zip: Boolean = true, -) : IExportParams + @field:Parameter( + description = """Message format to be used for export. (applicable for .po) + +e.g. PHP_PO: Hello %s + """, + ) + override var messageFormat: ExportMessageFormat? = null, +) : IExportParams { + override var supportArrays: Boolean = false +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/ComplexEditKeyDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/ComplexEditKeyDto.kt index 757ac8dbf5..a331350f91 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/ComplexEditKeyDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/ComplexEditKeyDto.kt @@ -38,6 +38,21 @@ data class ComplexEditKeyDto( @field:Size(max = 2000) @Schema(description = "Description of the key. It's also used as a context for Tolgee AI translator") val description: String? = null, + @Schema( + description = + "If key is pluralized. If it will be reflected in the editor. " + + "If null, value won't be modified.", + ) + val isPlural: Boolean? = null, + @Schema( + description = + "The argument name for the plural. " + + "If null, value won't be modified. " + + "If isPlural is false, this value will be ignored.", + ) + val pluralArgName: String? = null, + @Schema(description = "Custom values of the key. If not provided, custom values won't be modified") + val custom: Map? = null, ) : WithRelatedKeysInOrder { @JsonSetter("namespace") fun setJsonNamespace(namespace: String?) { diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt index 1b1944e1ed..5e6b6793b3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/key/CreateKeyDto.kt @@ -40,6 +40,14 @@ class CreateKeyDto( example = "This key is used on homepage. It's a label of sign up button.", ) val description: String? = null, + @Schema(description = "If key is pluralized. If it will be reflected in the editor") + val isPlural: Boolean = false, + @Schema( + description = + "The argument name for the plural. " + + "If null, value will be guessed from the values provided in translations.", + ) + val pluralArgName: String? = null, ) : WithRelatedKeysInOrder { @JsonSetter("namespace") fun setJsonNamespace(namespace: String?) { diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectDTO.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectRequest.kt similarity index 87% rename from backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectDTO.kt rename to backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectRequest.kt index d8d0147d72..b12b91cb14 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectDTO.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/CreateProjectRequest.kt @@ -9,7 +9,7 @@ import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size -data class CreateProjectDTO( +data class CreateProjectRequest( @field:NotBlank @field:Size(min = 3, max = 50) var name: String = "", @@ -33,4 +33,6 @@ data class CreateProjectDTO( "first language will be selected as base.", ) var baseLanguageTag: String? = null, + @Schema(description = "Whether to use ICU placeholder visualization in the editor and it's support.") + var icuPlaceholders: Boolean = true, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectDTO.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt similarity index 69% rename from backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectDTO.kt rename to backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt index 4a7fe2e7f6..8ef2eecb44 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectDTO.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/project/EditProjectRequest.kt @@ -1,10 +1,11 @@ package io.tolgee.dtos.request.project +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size -data class EditProjectDTO( +data class EditProjectRequest( @field:NotBlank @field:Size(min = 3, max = 50) var name: String = "", @@ -14,4 +15,6 @@ data class EditProjectDTO( var baseLanguageId: Long? = null, @field:Size(min = 3, max = 2000) var description: String? = null, + @Schema(description = "Whether to use ICU placeholder visualization in the editor and it's support.") + var icuPlaceholders: Boolean = true, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/BaseIcuMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/BaseIcuMessageConvertor.kt new file mode 100644 index 0000000000..5089138a0f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/BaseIcuMessageConvertor.kt @@ -0,0 +1,209 @@ +package io.tolgee.formats + +import com.ibm.icu.text.MessagePattern +import io.tolgee.constants.Message + +class BaseIcuMessageConvertor( + private val message: String, + private val argumentConvertor: FromIcuParamConvertor, + private val keepEscaping: Boolean = false, + private val forceIsPlural: Boolean? = null, +) { + companion object { + const val OTHER_KEYWORD = "other" + } + + private var pluralArgName: String? = null + + private var firstArgName: String? = null + + private lateinit var tree: MessagePatternUtil.MessageNode + + private fun addToResult( + value: String, + keyword: String? = null, + ) { + if (keyword == null) { + singleResult.append(value) + pluralFormsResult?.values?.forEach { it.append(value) } + otherResult?.append(value) + return + } + + if (pluralFormsResult == null) { + pluralFormsResult = mutableMapOf() + } + + pluralFormsResult?.compute(keyword) { _, v -> + (v ?: StringBuilder(singleResult)).append(value) + } + + if (keyword == OTHER_KEYWORD) { + if (otherResult == null) { + otherResult = StringBuilder(singleResult) + } + otherResult!!.append(value) + } + } + + /** + * We need to store all plural forms + */ + private var pluralFormsResult: MutableMap? = null + + private var otherResult: StringBuilder? = null + private var singleResult = StringBuilder() + + private val warnings = mutableListOf>>() + + fun convert(): PossiblePluralConversionResult { + return catchingCannotParse { + tree = MessagePatternUtil.buildMessageNode(message) + handleNode(tree) + + if ((pluralFormsResult == null && forceIsPlural != true) || forceIsPlural == false) { + return@catchingCannotParse getSingularResult() + } + getPluralResult() + } + } + + private fun getSingularResult(): PossiblePluralConversionResult { + return PossiblePluralConversionResult( + singleResult.toString(), + null, + null, + warnings, + firstArgName = firstArgName, + ) + } + + private fun catchingCannotParse(fn: () -> PossiblePluralConversionResult): PossiblePluralConversionResult { + try { + return fn() + } catch (e: Exception) { + if (forceIsPlural == true) { + val escaped = message.escapeIcu(true) + + return PossiblePluralConversionResult( + formsResult = mapOf("other" to escaped), + ) + } + return PossiblePluralConversionResult( + singleResult = message, + ) + } + } + + private fun getPluralResult(): PossiblePluralConversionResult { + val result = + pluralFormsResult + ?.mapValues { it.value.toString() } + ?.toMutableMap() ?: mutableMapOf() + + val otherResult = + if (forceIsPlural == true && otherResult == null) { + singleResult + } else { + otherResult ?: "" + } + + result.computeIfAbsent(OTHER_KEYWORD) { otherResult.toString() } + return PossiblePluralConversionResult( + null, + result, + pluralArgName, + warnings, + firstArgName = firstArgName, + ) + } + + private fun handleNode( + node: MessagePatternUtil.Node?, + form: String? = null, + ) { + when (node) { + is MessagePatternUtil.ArgNode -> { + handleArgNode(node, form) + } + + is MessagePatternUtil.TextNode -> { + appendFromTextNode(node, form) + } + + is MessagePatternUtil.MessageNode -> { + node.contents.forEach { + handleNode(it, form) + } + } + + is MessagePatternUtil.MessageContentsNode -> { + if (node.type == MessagePatternUtil.MessageContentsNode.Type.REPLACE_NUMBER) { + addToResult(argumentConvertor.convertReplaceNumber(node, pluralArgName), form) + } + } + + else -> { + } + } + } + + private fun appendFromTextNode( + node: MessagePatternUtil.TextNode, + form: String?, + ) { + if (keepEscaping) { + addToResult(node.patternString, form) + return + } + addToResult(node.text, form) + } + + private fun handleArgNode( + node: MessagePatternUtil.ArgNode, + form: String?, + ) { + if (firstArgName == null) { + firstArgName = node.name + } + when (node.argType) { + MessagePattern.ArgType.SIMPLE, MessagePattern.ArgType.NONE -> { + val isInPlural = form != null + addToResult(argumentConvertor.convert(node, isInPlural), form) + } + + MessagePattern.ArgType.PLURAL -> { + if (forceIsPlural == false) { + addToResult(node.patternString) + return + } + + if (!pluralFormsResult.isNullOrEmpty()) { + warnings.add(Message.MULTIPLE_PLURALS_NOT_SUPPORTED to listOf(node.patternString)) + addToResult(node.patternString, form) + return + } + if (form != null) { + warnings.add(Message.NESTED_PLURALS_NOT_SUPPORTED to listOf(node.patternString)) + addToResult(node.patternString, form) + return + } + handlePlural(node) + } + + else -> { + addToResult(node.patternString, form) + warnings.add(Message.ADVANCED_PARAMS_NOT_SUPPORTED to listOf(node.patternString)) + } + } + } + + private fun handlePlural(node: MessagePatternUtil.ArgNode) { + pluralArgName = node.name + node.complexStyle?.variants?.forEach { + handleNode(it.message, it.selector) + } ?: run { + addToResult(node.patternString) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/CollisionHandler.kt b/backend/data/src/main/kotlin/io/tolgee/formats/CollisionHandler.kt new file mode 100644 index 0000000000..9e3494c1a3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/CollisionHandler.kt @@ -0,0 +1,10 @@ +package io.tolgee.formats + +import io.tolgee.model.dataImport.ImportTranslation + +interface CollisionHandler { + /** + * Takes the colliding translations and returns the ones to be deleted + */ + fun handle(importTranslations: List): List? +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt new file mode 100644 index 0000000000..66b5ab630f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt @@ -0,0 +1,12 @@ +package io.tolgee.formats + +enum class ExportFormat(val extension: String, val mediaType: String) { + JSON("json", "application/json"), + XLIFF("xlf", "application/x-xliff+xml"), + PO("po", "text/x-gettext-translation"), + APPLE_STRINGS_STRINGSDICT("", ""), + APPLE_XLIFF("xliff", "application/x-xliff+xml"), + ANDROID_XML("xml", "application/xml"), + FLUTTER_ARB("arb", "application/json"), + PROPERTIES("properties", "text/plain"), +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ExportMessageFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ExportMessageFormat.kt new file mode 100644 index 0000000000..df74b6983d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ExportMessageFormat.kt @@ -0,0 +1,7 @@ +package io.tolgee.formats + +enum class ExportMessageFormat { + C_SPRINTF, + PHP_SPRINTF, +// PYTHON_SPRINTF, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/FormsToIcuPluralConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/FormsToIcuPluralConvertor.kt new file mode 100644 index 0000000000..6736ad0d77 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/FormsToIcuPluralConvertor.kt @@ -0,0 +1,33 @@ +package io.tolgee.formats + +class FormsToIcuPluralConvertor( + val forms: Map, + val argName: String = DEFAULT_PLURAL_ARGUMENT_NAME, + val optimize: Boolean = false, + val addNewLines: Boolean, +) { + fun convert(): String { + val newLineStringInit = if (addNewLines) "\n" else " " + val icuMsg = StringBuffer("{$argName, plural,$newLineStringInit") + forms.let { + if (optimize) { + return@let optimizePluralForms(it) + } + return@let it + }.entries.forEachIndexed { index, (keyword, message) -> + val isLast = index == forms.size - 1 + val newLineStringForm = + if (addNewLines) { + "\n" + } else if (isLast) { + "" + } else { + " " + } + + icuMsg.append("$keyword {$message}$newLineStringForm") + } + icuMsg.append("}") + return icuMsg.toString() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/FromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/FromIcuParamConvertor.kt new file mode 100644 index 0000000000..e17458f4a2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/FromIcuParamConvertor.kt @@ -0,0 +1,16 @@ +package io.tolgee.formats + +interface FromIcuParamConvertor { + fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String + + /** + * How to # in ICU plural form + */ + fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String? = null, + ): String +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ImportFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessor.kt similarity index 83% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ImportFileProcessor.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessor.kt index e24182db46..7c23510d27 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ImportFileProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessor.kt @@ -1,4 +1,6 @@ -package io.tolgee.service.dataImport.processors +package io.tolgee.formats + +import io.tolgee.service.dataImport.processors.FileProcessorContext abstract class ImportFileProcessor { abstract val context: FileProcessorContext diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt new file mode 100644 index 0000000000..135ea52f1c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt @@ -0,0 +1,52 @@ +package io.tolgee.formats + +import StringsdictFileProcessor +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.dtos.dataImport.ImportFileDto +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.android.`in`.AndroidStringsXmlProcessor +import io.tolgee.formats.apple.`in`.strings.StringsFileProcessor +import io.tolgee.formats.flutter.`in`.FlutterArbFileProcessor +import io.tolgee.formats.json.`in`.JsonFileProcessor +import io.tolgee.formats.po.`in`.PoFileProcessor +import io.tolgee.formats.properties.`in`.PropertiesFileProcessor +import io.tolgee.formats.xliff.`in`.XliffFileProcessor +import io.tolgee.service.dataImport.processors.FileProcessorContext +import io.tolgee.service.dataImport.processors.ImportArchiveProcessor +import io.tolgee.service.dataImport.processors.ZipTypeProcessor +import org.springframework.stereotype.Component + +@Component +class ImportFileProcessorFactory( + private val objectMapper: ObjectMapper, +) { + fun getArchiveProcessor(file: ImportFileDto): ImportArchiveProcessor { + return when (file.name.fileNameExtension) { + "zip" -> ZipTypeProcessor() + else -> throw ImportCannotParseFileException(file.name, "No matching processor") + } + } + + fun getProcessor( + file: ImportFileDto, + context: FileProcessorContext, + ): ImportFileProcessor { + return when (file.name.fileNameExtension) { + "json" -> JsonFileProcessor(context, objectMapper) + "po" -> PoFileProcessor(context) + "strings" -> StringsFileProcessor(context) + "stringsdict" -> StringsdictFileProcessor(context) + "xliff" -> XliffFileProcessor(context) + "xlf" -> XliffFileProcessor(context) + "properties" -> PropertiesFileProcessor(context) + "xml" -> AndroidStringsXmlProcessor(context) + "arb" -> FlutterArbFileProcessor(context, objectMapper) + else -> throw ImportCannotParseFileException(file.name, "No matching processor") + } + } + + val String?.fileNameExtension: String? + get() { + return this?.replace(".*\\.(.+)\$".toRegex(), "$1") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertor.kt new file mode 100644 index 0000000000..690dfff782 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertor.kt @@ -0,0 +1,10 @@ +package io.tolgee.formats + +interface ImportMessageConvertor { + fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean = true, + isProjectIcuEnabled: Boolean = true, + ): MessageConvertorResult +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertorType.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertorType.kt new file mode 100644 index 0000000000..6af6bccb92 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportMessageConvertorType.kt @@ -0,0 +1,21 @@ +package io.tolgee.formats + +import io.tolgee.formats.android.`in`.AndroidToIcuMessageConvertor +import io.tolgee.formats.apple.`in`.AppleToIcuMessageConvertor +import io.tolgee.formats.po.`in`.messageConvertors.PoCToIcuImportMessageConvertor +import io.tolgee.formats.po.`in`.messageConvertors.PoPhpToIcuImportMessageConvertor + +enum class ImportMessageConvertorType( + val importMessageConvertor: ImportMessageConvertor? = null, +) { + JSON, + PO_PHP(PoPhpToIcuImportMessageConvertor()), + PO_C(PoCToIcuImportMessageConvertor()), + +// PO_PYTHON(PoPythonToIcuImportMessageConvertor()), + STRINGS(AppleToIcuMessageConvertor()), + STRINGSDICT(AppleToIcuMessageConvertor()), + APPLE_XLIFF(AppleToIcuMessageConvertor()), + PROPERTIES, + ANDROID_XML(AndroidToIcuMessageConvertor()), +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorFactory.kt b/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorFactory.kt new file mode 100644 index 0000000000..8f5c6efde1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorFactory.kt @@ -0,0 +1,24 @@ +package io.tolgee.formats + +class MessageConvertorFactory( + private val message: String, + private val forceIsPlural: Boolean? = null, + private val isProjectIcuPlaceholdersEnabled: Boolean = false, + private val paramConvertorFactory: () -> FromIcuParamConvertor, +) { + fun create(): BaseIcuMessageConvertor { + if (!isProjectIcuPlaceholdersEnabled) { + return BaseIcuMessageConvertor( + message = message, + argumentConvertor = NoOpFromIcuParamConvertor(), + forceIsPlural = forceIsPlural, + ) + } + + return BaseIcuMessageConvertor( + message = message, + argumentConvertor = paramConvertorFactory(), + forceIsPlural = forceIsPlural, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorResult.kt b/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorResult.kt new file mode 100644 index 0000000000..e110eb9023 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/MessageConvertorResult.kt @@ -0,0 +1,3 @@ +package io.tolgee.formats + +data class MessageConvertorResult(val message: String?, val isPlural: Boolean) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/MessagePatternUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/MessagePatternUtil.kt new file mode 100644 index 0000000000..757689edf8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/MessagePatternUtil.kt @@ -0,0 +1,644 @@ +package io.tolgee.formats + +import com.ibm.icu.text.MessagePattern +import com.ibm.icu.text.MessagePatternUtil +import java.util.* +import kotlin.concurrent.Volatile + +/** + * Original license: + * © 2016 and later: Unicode, Inc. and others. + * License & terms of use: http://www.unicode.org/copyright.html + ******************************************************************************* + * Copyright (C) 2011-2014, International Business Machines + * Corporation and others. All Rights Reserved. + ******************************************************************************* + * created on: 2011jul14 + * created by: Markus W. Scherer + * + ******************************************* + * + * Tolgee docs: + * + * We took this file from ICU4J and added tools to propertly get part of original message from ICU message pattern. + * We need this to reliable convert ICU message to plural forms. + * + ******************************************** + * + * Original docs: + * + * Utilities for working with a MessagePattern. + * Intended for use in tools when convenience is more important than + * minimizing runtime and object creations. + * + * + * This class only has static methods. + * Each of the nested classes is immutable and thread-safe. + * + * + * This class and its nested classes are not intended for public subclassing. + * @stable ICU 49 + * @author Markus Scherer + */ +object MessagePatternUtil { + /** + * Factory method, builds and returns a MessageNode from a MessageFormat pattern string. + * @param patternString a MessageFormat pattern string + * @return a MessageNode or a ComplexArgStyleNode + * @throws IllegalArgumentException if the MessagePattern is empty + * or does not represent a MessageFormat pattern + * @stable ICU 49 + */ + fun buildMessageNode(patternString: String?): MessageNode { + return buildMessageNode(MessagePattern(patternString)) + } + + /** + * Factory method, builds and returns a MessageNode from a MessagePattern. + * @param pattern a parsed MessageFormat pattern string + * @return a MessageNode or a ComplexArgStyleNode + * @throws IllegalArgumentException if the MessagePattern is empty + * or does not represent a MessageFormat pattern + * @stable ICU 49 + */ + fun buildMessageNode(pattern: MessagePattern): MessageNode { + val limit = pattern.countParts() - 1 + require(limit >= 0) { "The MessagePattern is empty" } + require(pattern.getPartType(0) == MessagePattern.Part.Type.MSG_START) { + "The MessagePattern does not represent a MessageFormat pattern" + } + return buildMessageNode(pattern, 0, limit) + } + + private fun buildMessageNode( + pattern: MessagePattern, + start: Int, + limit: Int, + ): MessageNode { + var prevPatternIndex = pattern.getPart(start).limit + val node = MessageNode(pattern, start, limit) + var i = start + 1 + while (true) { + var part = pattern.getPart(i) + val patternIndex = part.index + val isSkipSyntax = part.type == MessagePattern.Part.Type.SKIP_SYNTAX + if (prevPatternIndex < patternIndex) { + val text = + pattern.patternString.substring( + prevPatternIndex, + patternIndex, + ) + + node.addContentsNode( + TextNode(pattern, text, text, start = i - 1, limit = i), + ) + } + + if (isSkipSyntax) { + node.addContentsNode( + TextNode(pattern, "", "'", start = i - 1, limit = i), + ) + } + + if (i == limit) { + break + } + val partType = part.type + if (partType == MessagePattern.Part.Type.ARG_START) { + val argLimit = pattern.getLimitPartIndex(i) + node.addContentsNode(buildArgNode(pattern, i, argLimit)) + i = argLimit + part = pattern.getPart(i) + } else if (partType == MessagePattern.Part.Type.REPLACE_NUMBER) { + node.addContentsNode(MessageContentsNode.createReplaceNumberNode(pattern, i)) + // else: ignore SKIP_SYNTAX and INSERT_CHAR parts. + } + prevPatternIndex = part.limit + ++i + } + return node.freeze() + } + + private fun buildArgNode( + pattern: MessagePattern, + start: Int, + limit: Int, + ): ArgNode { + var start = start + val node: ArgNode = ArgNode.createArgNode(pattern, start, limit) + var part = pattern.getPart(start) + node.argType = part.argType + val argType = node.argType + part = pattern.getPart(++start) // ARG_NAME or ARG_NUMBER + node.name = pattern.getSubstring(part) + if (part.type == MessagePattern.Part.Type.ARG_NUMBER) { + node.number = part.value + } + ++start + when (argType) { + MessagePattern.ArgType.SIMPLE -> { + // ARG_TYPE + node.typeName = pattern.getSubstring(pattern.getPart(start++)) + if (start < limit) { + // ARG_STYLE + node.style = pattern.getSubstring(pattern.getPart(start)) + } + } + + MessagePattern.ArgType.CHOICE -> { + node.typeName = "choice" + node.complexStyle = buildChoiceStyleNode(pattern, start, limit) + } + + MessagePattern.ArgType.PLURAL -> { + node.typeName = "plural" + node.complexStyle = buildPluralStyleNode(pattern, start, limit, argType) + } + + MessagePattern.ArgType.SELECT -> { + node.typeName = "select" + node.complexStyle = buildSelectStyleNode(pattern, start, limit) + } + + MessagePattern.ArgType.SELECTORDINAL -> { + node.typeName = "selectordinal" + node.complexStyle = buildPluralStyleNode(pattern, start, limit, argType) + } + + else -> {} + } + return node + } + + private fun buildChoiceStyleNode( + pattern: MessagePattern, + start: Int, + limit: Int, + ): ComplexArgStyleNode { + var start = start + val node = ComplexArgStyleNode(pattern, MessagePattern.ArgType.CHOICE, start, limit) + while (start < limit) { + val valueIndex = start + val part = pattern.getPart(start) + val value = pattern.getNumericValue(part) + start += 2 + val msgLimit = pattern.getLimitPartIndex(start) + val variant = VariantNode(pattern, start, msgLimit) + variant.selector = pattern.getSubstring(pattern.getPart(valueIndex + 1)) + variant.numericValue = value + variant.msgNode = buildMessageNode(pattern, start, msgLimit) + node.addVariant(variant) + start = msgLimit + 1 + } + return node.freeze() + } + + private fun buildPluralStyleNode( + pattern: MessagePattern, + start: Int, + limit: Int, + argType: MessagePattern.ArgType, + ): ComplexArgStyleNode { + var start = start + val node = ComplexArgStyleNode(pattern, argType, start, limit) + val offset = pattern.getPart(start) + if (offset.type.hasNumericValue()) { + node.explicitOffset = true + node.offset = pattern.getNumericValue(offset) + ++start + } + while (start < limit) { + val selector = pattern.getPart(start++) + var value = MessagePattern.NO_NUMERIC_VALUE + val part = pattern.getPart(start) + if (part.type.hasNumericValue()) { + value = pattern.getNumericValue(part) + ++start + } + val msgLimit = pattern.getLimitPartIndex(start) + val variant = VariantNode(pattern, start, msgLimit) + variant.selector = pattern.getSubstring(selector) + variant.numericValue = value + variant.msgNode = buildMessageNode(pattern, start, msgLimit) + node.addVariant(variant) + start = msgLimit + 1 + } + return node.freeze() + } + + private fun buildSelectStyleNode( + pattern: MessagePattern, + start: Int, + limit: Int, + ): ComplexArgStyleNode { + var start = start + val node = ComplexArgStyleNode(pattern, MessagePattern.ArgType.SELECT, start, limit) + while (start < limit) { + val selector = pattern.getPart(start++) + val msgLimit = pattern.getLimitPartIndex(start) + val variant = VariantNode(pattern, start, msgLimit) + variant.selector = pattern.getSubstring(selector) + variant.msgNode = buildMessageNode(pattern, start, msgLimit) + node.addVariant(variant) + start = msgLimit + 1 + } + return node.freeze() + } + + /** + * Common base class for all elements in a tree of nodes + * returned by [MessagePatternUtil.buildMessageNode]. + * This class and all subclasses are immutable and thread-safe. + * @stable ICU 49 + */ + abstract class Node( + protected val owningPattern: MessagePattern, + val start: Int, + val limit: Int, + ) { + open val patternString: String by lazy { + val startPart = owningPattern.getPart(start).index + val endPart = owningPattern.getPart(limit).limit + owningPattern.patternString.subSequence(startPart, endPart).toString() + } + } + + /** + * A Node representing a parsed MessageFormat pattern string. + * @stable ICU 49 + */ + class MessageNode( + val pattern: MessagePattern, + start: Int, + limit: Int, + ) : Node(pattern, start, limit) { + val contents: List + /** + * @return the list of MessageContentsNode nodes that this message contains + * @stable ICU 49 + */ + get() = list + + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + return list.toString() + } + + fun addContentsNode(node: MessageContentsNode) { + if (node is TextNode && !list.isEmpty()) { + // Coalesce adjacent text nodes. + val lastNode = list[list.size - 1] + if (lastNode is TextNode) { + val textNode = lastNode + textNode.text += node.text + textNode.patternString += node.patternString + return + } + } + list.add(node) + } + + fun freeze(): MessageNode { + list = Collections.unmodifiableList(list) + return this + } + + @Volatile + private var list: MutableList = ArrayList() + } + + /** + * A piece of MessageNode contents. + * Use getType() to determine the type and the actual Node subclass. + * @stable ICU 49 + */ + open class MessageContentsNode( + /** + * Returns the type of this piece of MessageNode contents. + * @stable ICU 49 + */ + owningPattern: MessagePattern, + val type: Type, + start: Int, + limit: Int, + ) : + Node(owningPattern, start, limit) { + /** + * The type of a piece of MessageNode contents. + * @stable ICU 49 + */ + enum class Type { + /** + * This is a TextNode containing literal text (downcast and call getText()). + * @stable ICU 49 + */ + TEXT, + + /** + * This is an ArgNode representing a message argument + * (downcast and use specific methods). + * @stable ICU 49 + */ + ARG, + + /** + * This Node represents a place in a plural argument's variant where + * the formatted (plural-offset) value is to be put. + * @stable ICU 49 + */ + REPLACE_NUMBER, + } + + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + // Note: There is no specific subclass for REPLACE_NUMBER + // because it would not provide any additional API. + // Therefore we have a little bit of REPLACE_NUMBER-specific code + // here in the contents-node base class. + return "{REPLACE_NUMBER}" + } + + companion object { + fun createReplaceNumberNode( + owningPattern: MessagePattern, + start: Int, + ): MessageContentsNode { + return MessageContentsNode(owningPattern, Type.REPLACE_NUMBER, start, start) + } + } + } + + /** + * Literal text, a piece of MessageNode contents. + * @stable ICU 49 + */ + class TextNode( + owningPattern: MessagePattern, + /** + * @return the literal text at this point in the message + * @stable ICU 49 + */ + var text: String, + override var patternString: String, + start: Int, + limit: Int, + ) : MessageContentsNode(owningPattern, Type.TEXT, start, limit) { + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + return "«$text»" + } + } + + /** + * A piece of MessageNode contents representing a message argument and its details. + * @stable ICU 49 + */ + class ArgNode private constructor( + owningPattern: MessagePattern, + start: Int, + limit: Int, + ) : MessageContentsNode(owningPattern, Type.ARG, start, limit) { + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + val sb = StringBuilder() + sb.append('{').append(name) + if (argType != MessagePattern.ArgType.NONE) { + sb.append(',').append(typeName) + if (argType == MessagePattern.ArgType.SIMPLE) { + if (simpleStyle != null) { + sb.append(',').append(simpleStyle) + } + } else { + sb.append(',').append(complexStyle.toString()) + } + } + return sb.append('}').toString() + } + + var style: String? = null + + /** + * @return the argument type + * @stable ICU 49 + */ + var argType: MessagePattern.ArgType? = null + + /** + * @return the argument name string (the decimal-digit string if the argument has a number) + * @stable ICU 49 + */ + var name: String? = null + + /** + * @return the argument number, or -1 if none (for a named argument) + * @stable ICU 49 + */ + var number: Int = -1 + + /** + * @return the argument type string, or null if none was specified + * @stable ICU 49 + */ + var typeName: String? = null + + /** + * @return the simple-argument style string, + * or null if no style is specified and for other argument types + * @stable ICU 49 + */ + val simpleStyle: String + get() = style ?: "" + + /** + * @return the complex-argument-style object, + * or null if the argument type is NONE_ARG or SIMPLE_ARG + * @stable ICU 49 + */ + var complexStyle: ComplexArgStyleNode? = null + + companion object { + fun createArgNode( + owningPattern: MessagePattern, + start: Int, + limit: Int, + ): ArgNode { + return ArgNode(owningPattern, start, limit) + } + } + } + + /** + * A Node representing details of the argument style of a complex argument. + * (Which is a choice/plural/select argument which selects among nested messages.) + * @stable ICU 49 + */ + class ComplexArgStyleNode( + owningPattern: MessagePattern, + /** + * @return the argument type (same as getArgType() on the parent ArgNode) + * @stable ICU 49 + */ + val argType: MessagePattern.ArgType, + start: Int, + limit: Int, + ) : Node(owningPattern, start, limit) { + /** + * @return true if this is a plural style with an explicit offset + * @stable ICU 49 + */ + fun hasExplicitOffset(): Boolean { + return explicitOffset + } + + val variants: List + /** + * @return the list of variants: the nested messages with their selection criteria + * @stable ICU 49 + */ + get() = list + + /** + * Separates the variants by type. + * Intended for use with plural and select argument styles, + * not useful for choice argument styles. + * + * + * Both parameters are used only for output, and are first cleared. + * @param numericVariants Variants with numeric-value selectors (if any) are added here. + * Can be null for a select argument style. + * @param keywordVariants Variants with keyword selectors, except "other", are added here. + * For a plural argument, if this list is empty after the call, then + * all variants except "other" have explicit values + * and PluralRules need not be called. + * @return the "other" variant (the first one if there are several), + * null if none (choice style) + * @stable ICU 49 + */ + fun getVariantsByType( + numericVariants: MutableList?, + keywordVariants: MutableList, + ): VariantNode? { + numericVariants?.clear() + keywordVariants.clear() + var other: VariantNode? = null + for (variant in list) { + if (variant.isSelectorNumeric) { + numericVariants!!.add(variant) + } else if ("other" == variant.selector) { + if (other == null) { + // Return the first "other" variant. (MessagePattern allows duplicates.) + other = variant + } + } else { + keywordVariants.add(variant) + } + } + return other + } + + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + val sb = StringBuilder() + sb.append('(').append(argType.toString()).append(" style) ") + if (hasExplicitOffset()) { + sb.append("offset:").append(offset).append(' ') + } + return sb.append(list.toString()).toString() + } + + fun addVariant(variant: VariantNode) { + list.add(variant) + } + + fun freeze(): ComplexArgStyleNode { + list = Collections.unmodifiableList(list) + return this + } + + /** + * @return the plural offset, or 0 if this is not a plural style or + * the offset is explicitly or implicitly 0 + * @stable ICU 49 + */ + var offset: Double = 0.0 + var explicitOffset = false + + @Volatile + private var list: MutableList = ArrayList() + } + + /** + * A Node representing a nested message (nested inside an argument) + * with its selection criterion. + * @stable ICU 49 + */ + class VariantNode( + owningPattern: MessagePattern, + start: Int, + limit: Int, + ) : Node(owningPattern, start, limit) { + val isSelectorNumeric: Boolean + /** + * @return true for choice variants and for plural explicit values + * @stable ICU 49 + */ + get() = selectorValue != MessagePattern.NO_NUMERIC_VALUE + + /** + * {@inheritDoc} + * @stable ICU 49 + */ + override fun toString(): String { + val sb = StringBuilder() + if (isSelectorNumeric) { + sb.append(selectorValue).append(" (").append(selector).append(") {") + } else { + sb.append(selector).append(" {") + } + return sb.append(message.toString()).append('}').toString() + } + + /** + * Returns the selector string. + * For example: A plural/select keyword ("few"), a plural explicit value ("=1"), + * a choice comparison operator ("#"). + * @return the selector string + * @stable ICU 49 + */ + var selector: String? = null + + /** + * @return the selector's numeric value, or NO_NUMERIC_VALUE if !isSelectorNumeric() + * @stable ICU 49 + */ + var selectorValue: Double = MessagePattern.NO_NUMERIC_VALUE + + /** + * @return the nested message + * @stable ICU 49 + */ + val message: MessageNode? + get() = msgNode + + var numericValue = MessagePattern.NO_NUMERIC_VALUE + var msgNode: MessageNode? = null + + override val patternString: String + get() = msgNode?.contents?.joinToString("") { it.patternString } ?: "" + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/NoOpFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/NoOpFromIcuParamConvertor.kt new file mode 100644 index 0000000000..b0272f9009 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/NoOpFromIcuParamConvertor.kt @@ -0,0 +1,15 @@ +package io.tolgee.formats + +class NoOpFromIcuParamConvertor : FromIcuParamConvertor { + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + return node.patternString + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String = node.patternString +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/PossiblePluralConversionResult.kt b/backend/data/src/main/kotlin/io/tolgee/formats/PossiblePluralConversionResult.kt new file mode 100644 index 0000000000..425c125257 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/PossiblePluralConversionResult.kt @@ -0,0 +1,21 @@ +package io.tolgee.formats + +import io.tolgee.constants.Message + +class PossiblePluralConversionResult( + val singleResult: String? = null, + val formsResult: Map? = null, + val argName: String? = null, + val warnings: List>> = emptyList(), + val firstArgName: String? = null, +) { + init { + if (singleResult == null && formsResult == null) { + throw IllegalArgumentException("Both result and forms cannot be null") + } + } + + fun isPlural(): Boolean { + return formsResult != null + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/StringWrapper.kt b/backend/data/src/main/kotlin/io/tolgee/formats/StringWrapper.kt new file mode 100644 index 0000000000..22a5890dd2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/StringWrapper.kt @@ -0,0 +1,5 @@ +package io.tolgee.formats + +data class StringWrapper( + val _stringValue: String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ToIcuParamConvertor.kt new file mode 100644 index 0000000000..9ebae8cbc7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ToIcuParamConvertor.kt @@ -0,0 +1,10 @@ +package io.tolgee.formats + +interface ToIcuParamConvertor { + fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String + + val regex: Regex +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/androidStringsXmlModel.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/androidStringsXmlModel.kt new file mode 100644 index 0000000000..2388241600 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/androidStringsXmlModel.kt @@ -0,0 +1,24 @@ +package io.tolgee.formats.android + +class AndroidStringsXmlModel { + val items: MutableMap = mutableMapOf() +} + +class StringUnit : AndroidXmlNode { + var value: String? = null +} + +class StringArrayUnit : AndroidXmlNode { + val items = mutableListOf() +} + +class StringArrayItem( + var value: String? = null, + var index: Int? = null, +) : AndroidXmlNode + +class PluralUnit : AndroidXmlNode { + val items = mutableMapOf() +} + +interface AndroidXmlNode diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlParser.kt new file mode 100644 index 0000000000..c4f334de82 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlParser.kt @@ -0,0 +1,127 @@ +import io.tolgee.formats.android.AndroidStringsXmlModel +import io.tolgee.formats.android.PluralUnit +import io.tolgee.formats.android.StringArrayItem +import io.tolgee.formats.android.StringArrayUnit +import io.tolgee.formats.android.StringUnit +import java.io.StringWriter +import javax.xml.namespace.QName +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLEventWriter +import javax.xml.stream.XMLOutputFactory +import javax.xml.stream.events.StartElement + +class AndroidStringsXmlParser( + private val xmlEventReader: XMLEventReader, +) { + private val result = AndroidStringsXmlModel() + private var currentStringEntry: StringUnit? = null + private var currentArrayEntry: StringArrayUnit? = null + private var currentPluralEntry: PluralUnit? = null + private var currentPluralQuantity: String? = null + private var sw = StringWriter() + private var xw: XMLEventWriter? = null + private val of: XMLOutputFactory = XMLOutputFactory.newDefaultFactory() + private var isArrayItemOpen = false + + fun parse(): AndroidStringsXmlModel { + while (xmlEventReader.hasNext()) { + val event = xmlEventReader.nextEvent() + val wasAnyToContentSaveOpenBefore = isAnyToContentSaveOpen + when { + event.isStartElement -> { + if (!isAnyToContentSaveOpen) { + sw = StringWriter() + xw = of.createXMLEventWriter(sw) + } + val startElement = event as StartElement + when (startElement.name.localPart.lowercase()) { + "string" -> { + val stringEntry = StringUnit() + getKeyName(startElement)?.let { keyName -> + currentStringEntry = stringEntry + result.items[keyName] = stringEntry + } + } + + "string-array" -> { + val arrayEntry = StringArrayUnit() + getKeyName(startElement)?.let { keyName -> + currentArrayEntry = arrayEntry + result.items[keyName] = arrayEntry + } + } + + "item" -> { + if (currentPluralEntry != null) { + currentPluralQuantity = startElement.getAttributeByName(QName(null, "quantity"))?.value + } else if (currentArrayEntry != null) { + isArrayItemOpen = true + } + } + + "plurals" -> { + val pluralEntry = PluralUnit() + getKeyName(startElement)?.let { keyName -> + currentPluralEntry = pluralEntry + result.items[keyName] = pluralEntry + } + } + } + } + + event.isEndElement -> { + when (event.asEndElement().name.localPart.lowercase()) { + "string" -> { + currentStringEntry?.value = getCurrentTextOrXml() + currentStringEntry = null + } + + "item" -> { + if (currentPluralEntry != null) { + if (currentPluralQuantity != null) { + currentPluralEntry!!.items[currentPluralQuantity!!] = getCurrentTextOrXml() + currentPluralQuantity = null + } + } else if (isArrayItemOpen) { + val index = currentArrayEntry?.items?.size ?: 0 + currentArrayEntry?.items?.add(StringArrayItem(getCurrentTextOrXml(), index)) + isArrayItemOpen = false + } + } + + "plurals" -> { + currentPluralEntry = null + } + + "string-array" -> { + currentArrayEntry = null + } + } + } + } + + if (isAnyToContentSaveOpen) { + if (wasAnyToContentSaveOpenBefore) { + xw?.add(event) + } + } else { + xw?.close() + } + } + + return result + } + + private fun getKeyName(startElement: StartElement) = startElement.getAttributeByName(QName(null, "name"))?.value + + private fun getCurrentTextOrXml(): String { + return sw.toString() + // android doesn't seem to support xml:space="preserve" + .trim() + } + + private val isAnyToContentSaveOpen: Boolean + get() { + return currentStringEntry != null || isArrayItemOpen || currentPluralQuantity != null + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlProcessor.kt new file mode 100644 index 0000000000..39e63ce2f9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidStringsXmlProcessor.kt @@ -0,0 +1,119 @@ +package io.tolgee.formats.android.`in` + +import AndroidStringsXmlParser +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.StringWrapper +import io.tolgee.formats.android.PluralUnit +import io.tolgee.formats.android.StringArrayUnit +import io.tolgee.formats.android.StringUnit +import io.tolgee.service.dataImport.processors.FileProcessorContext +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLInputFactory + +class AndroidStringsXmlProcessor(override val context: FileProcessorContext) : ImportFileProcessor() { + override fun process() { + val parsed = AndroidStringsXmlParser(xmlEventReader).parse() + + parsed.items.forEach { (keyName, item) -> + when (item) { + is StringUnit -> handleString(keyName, item) + is PluralUnit -> handlePlural(keyName, item) + is StringArrayUnit -> handleStringsArray(keyName, item) + else -> {} + } + } + } + + private fun handleString( + keyName: String, + it: StringUnit, + ) { + if (keyName.isBlank()) { + return + } + context.addTranslation( + keyName, + guessedLanguage, + convertMessage(it.value), + forceIsPlural = false, + rawData = StringWrapper(it.value), + convertedBy = ImportMessageConvertorType.ANDROID_XML, + ) + } + + private fun handlePlural( + keyName: String, + it: PluralUnit, + ) { + if (keyName.isBlank()) { + return + } + + val converted = + AndroidToIcuMessageConvertor().convert( + rawData = it.items, + languageTag = guessedLanguage, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + context.projectIcuPlaceholdersEnabled, + ) + + context.addTranslation( + keyName, + guessedLanguage, + converted.message, + forceIsPlural = true, + rawData = it.items, + convertedBy = ImportMessageConvertorType.ANDROID_XML, + ) + } + + private fun handleStringsArray( + keyName: String, + arrayUnit: StringArrayUnit, + ) { + if (keyName.isBlank()) { + return + } + arrayUnit.items.forEachIndexed { index, item -> + val keyNameWithIndex = "$keyName[$index]" + context.addTranslation( + keyNameWithIndex, + guessedLanguage, + convertMessage(item.value), + forceIsPlural = false, + rawData = StringWrapper(item.value), + convertedBy = ImportMessageConvertorType.ANDROID_XML, + ) + } + } + + private val guessedLanguage: String by lazy { + val matchResult = LANGUAGE_GUESS_REGEX.find(context.file.name) ?: return@lazy "unknown" + val language = matchResult.groups["language"]!!.value + val region = matchResult.groups["region"]?.value + val regionString = region?.let { "-$it" } ?: "" + "$language$regionString" + } + + private val xmlEventReader: XMLEventReader by lazy { + val inputFactory: XMLInputFactory = XMLInputFactory.newInstance() + inputFactory.createXMLEventReader(context.file.data.inputStream()) + } + + private fun convertMessage(message: String?): String? { + if (message == null) { + return null + } + return AndroidToIcuMessageConvertor().convert( + message, + guessedLanguage, + context.importSettings.convertPlaceholdersToIcu, + context.projectIcuPlaceholdersEnabled, + ).message + } + + companion object { + val LANGUAGE_GUESS_REGEX = Regex("values-(?[a-zA-Z]{2,3})(-r(?[a-zA-Z]{2,3}))?") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidToIcuMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidToIcuMessageConvertor.kt new file mode 100644 index 0000000000..9c1880b4a8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/AndroidToIcuMessageConvertor.kt @@ -0,0 +1,69 @@ +package io.tolgee.formats.android.`in` + +import io.tolgee.formats.FormsToIcuPluralConvertor +import io.tolgee.formats.ImportMessageConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.convertMessage + +class AndroidToIcuMessageConvertor : ImportMessageConvertor { + @Suppress("UNCHECKED_CAST") + override fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + val stringValue = rawData as? String ?: (rawData as? Map<*, *>)?.get("_stringValue") as? String + + if (stringValue is String) { + val converted = convert(stringValue, false, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, false) + } + + if (rawData is Map<*, *>) { + val rawDataMap = rawData as Map + val converted = convertPlural(rawDataMap, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, true) + } + + if (rawData == null) { + return MessageConvertorResult(null, false) + } + + throw IllegalArgumentException("Unsupported type of message") + } + + private fun convertPlural( + rawData: Map, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + val forms = + rawData.mapNotNull { + val converted = convert(it.value, true, convertPlaceholders, isProjectIcuEnabled) + it.key to (converted ?: return@mapNotNull null) + }.toMap() + + return FormsToIcuPluralConvertor( + forms, + addNewLines = true, + argName = "0", + ).convert() + } + + private fun convert( + message: String, + isInPlural: Boolean = false, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + return convertMessage( + message, + isInPlural, + convertPlaceholders = convertPlaceholders, + isProjectIcuEnabled = isProjectIcuEnabled, + ) { + JavaToIcuParamConvertor() + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/in/JavaToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/JavaToIcuParamConvertor.kt new file mode 100644 index 0000000000..719f723eaf --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/in/JavaToIcuParamConvertor.kt @@ -0,0 +1,66 @@ +package io.tolgee.formats.android.`in` + +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertFloatToIcu +import io.tolgee.formats.escapeIcu +import io.tolgee.formats.po.`in`.CLikeParameterParser +import io.tolgee.formats.usesUnsupportedFeature + +class JavaToIcuParamConvertor() : ToIcuParamConvertor { + private val parser = CLikeParameterParser() + private var index = 0 + + override val regex: Regex + get() = REGEX + + override fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String { + index++ + val parsed = parser.parse(matchResult) ?: return matchResult.value.escapeIcu(isInPlural) + + if (usesUnsupportedFeature(parsed)) { + return parsed.fullMatch.escapeIcu(isInPlural) + } + + if (parsed.specifier == "%") { + return "%" + } + + val zeroIndexedArgNum = parsed.argNum?.toIntOrNull()?.minus(1) + val name = zeroIndexedArgNum?.toString() ?: ((index - 1).toString()) + val isValidPluralReplaceNumber = parsed.specifier == "d" && name == "0" + + if (isInPlural && isValidPluralReplaceNumber) { + return "#" + } + + if (isValidPluralReplaceNumber) { + return "{$name, number}" + } + + when (parsed.specifier) { + "s" -> return "{$name}" + "e" -> return "{$name, number, scientific}" + "d" -> return "{$name, number}" + "f" -> return convertFloatToIcu(parsed, name) ?: parsed.fullMatch.escapeIcu(isInPlural) + } + + return parsed.fullMatch.escapeIcu(isInPlural) + } + + companion object { + val REGEX = + """ + (?x)( + % + (?:(?\d+)${"\\$"})? + (?[-\#+\s0,(]+)? + (?\d+)? + (?:\.(?\d+))? + (?[bBhHsScCdoxXeEfgGaAtT%nRrDF]) + ) + """.trimIndent().toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlExporter.kt new file mode 100644 index 0000000000..09cba2632b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlExporter.kt @@ -0,0 +1,206 @@ +package io.tolgee.formats.android.out + +import com.ibm.icu.util.ULocale +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.PossiblePluralConversionResult +import io.tolgee.formats.android.AndroidStringsXmlModel +import io.tolgee.formats.android.AndroidXmlNode +import io.tolgee.formats.android.PluralUnit +import io.tolgee.formats.android.StringArrayItem +import io.tolgee.formats.android.StringArrayUnit +import io.tolgee.formats.android.StringUnit +import io.tolgee.formats.populateForms +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class AndroidStringsXmlExporter( + override val translations: List, + override val exportParams: IExportParams, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + /** + * Map (Path To file -> Map (Key Name -> Node Wrapper)) + */ + private val fileUnits = mutableMapOf>() + + private fun getModels(): Map { + prepare() + + return fileUnits.map { (pathToFile, units) -> + val model = AndroidStringsXmlModel() + + units.forEach { + model.items[it.key] = it.value.node + } + + pathToFile to model + }.toMap() + } + + private fun prepare() { + translations.forEach { translation -> + val arrayMatch = KEY_IS_ARRAY_REGEX.matchEntire(translation.key.name) + val isArray = arrayMatch != null + when { + isArray -> buildStringArrayUnit(translation, arrayMatch!!) + else -> { + val converted = getConvertedMessage(translation, translation.key.isPlural) + when { + converted.isPlural() -> buildPluralsUnit(translation, converted.formsResult!!) + else -> buildStringUnit(translation, converted.singleResult!!) + } + } + } + } + } + + private fun buildStringUnit( + translation: ExportTranslationView, + text: String, + ) { + val stringUnit = + StringUnit().apply { + this.value = text + } + addToUnits(translation, stringUnit) + } + + private fun buildStringArrayUnit( + translation: ExportTranslationView, + arrayMatch: MatchResult, + ) { + // Assuming your translation view contains an array of strings as value for string arrays + val index = arrayMatch.groups["index"]?.value?.toIntOrNull() ?: return + val keyNameWithoutIndex = arrayMatch.groups["name"]?.value ?: return + addStringArrayUnitItem(keyNameWithoutIndex, index, translation) + } + + private fun addStringArrayUnitItem( + keyNameWithoutIndex: String, + index: Int, + translation: ExportTranslationView, + ) { + val normalizedKeyName = keyNameWithoutIndex.normalizedKeyName() + val isExactKeyName = normalizedKeyName == keyNameWithoutIndex + val units = getFileUnits(translation.languageTag) + val text = getConvertedMessage(translation, false).singleResult!! + units.compute(normalizedKeyName) { _, stringsArrayWrapper -> + when { + // it does not exist yer, or was created from key which have been normalized to different value + // in that case key without normalizing takes precedence + stringsArrayWrapper == null || (!stringsArrayWrapper.isExactKeyName && isExactKeyName) -> { + NodeWrapper( + StringArrayUnit().apply { + this.items.add(StringArrayItem(text, index)) + }, + isExactKeyName, + keyNameWithoutIndex, + ) + } + + // it is already a string array and the key is the same + (stringsArrayWrapper.node is StringArrayUnit && keyNameWithoutIndex == stringsArrayWrapper.exactKeyName) -> { + stringsArrayWrapper.node.items.add(StringArrayItem(text, index)) + stringsArrayWrapper + } + + else -> { + stringsArrayWrapper + } + } + } + } + + private fun buildPluralsUnit( + translation: ExportTranslationView, + pluralForms: Map, + ) { + // Assuming your translation view contain a map of plural forms as value + val pluralMap = populateForms(translation.languageTag, pluralForms) + val pluralUnit = + PluralUnit().apply { + this.items.putAll(pluralMap) + } + + addToUnits(translation, pluralUnit) + } + + companion object { + val KEY_IS_ARRAY_REGEX by lazy { + Regex("(?.*)\\[(?\\d+)\\]$") + } + val KEY_REPLACE_REGEX by lazy { + Regex("[^a-zA-Z0-9_]") + } + } + + private fun addToUnits( + translation: ExportTranslationView, + node: AndroidXmlNode, + ) { + val keyName = translation.key.name + val normalizedName = keyName.normalizedKeyName() + val isExactKeyName = normalizedName == keyName + val units = getFileUnits(translation.languageTag) + val existingUnit = units[normalizedName] + + // key with exact name was added before + if (existingUnit != null && existingUnit.isExactKeyName) { + return + } + + units[normalizedName] = NodeWrapper(node, isExactKeyName, keyName) + } + + private fun String.normalizedKeyName() = replace(KEY_REPLACE_REGEX, "_").replace("__", "_") + + private class NodeWrapper( + val node: AndroidXmlNode, + // is the keyName same as before normalization + val isExactKeyName: Boolean, + // the key name before normalization + val exactKeyName: String, + ) + + private fun getFileUnits(languageTag: String): MutableMap { + val folderName = convertBCP47ToAndroidResourceFormat(languageTag) + val filePath = "$folderName/strings.xml" + return fileUnits.computeIfAbsent(filePath) { mutableMapOf() } + } + + private fun convertBCP47ToAndroidResourceFormat(languageTag: String): String { + val uLocale = ULocale.forLanguageTag(languageTag) + val language = uLocale.language + val country = uLocale.country // assuming you have a region in your bcp47Tag + + return if (country.isEmpty()) { + "values-$language" + } else { + "values-$language-r$country" + } + } + + private fun getConvertedMessage( + translation: ExportTranslationView, + isPlural: Boolean = translation.key.isPlural, + ): PossiblePluralConversionResult { + val converted = + IcuToJavaMessageConvertor( + translation.text ?: "", + isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + + return converted + } + + override val fileExtension: String + get() = "xml" + + override fun produceFiles(): Map { + return getModels().map { (path, model) -> + path to AndroidStringsXmlFileWriter(model, true).produceFiles() + }.toMap() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlFileWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlFileWriter.kt new file mode 100644 index 0000000000..5ecf505bd0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/AndroidStringsXmlFileWriter.kt @@ -0,0 +1,66 @@ +package io.tolgee.formats.android.out + +import io.tolgee.formats.android.AndroidStringsXmlModel +import io.tolgee.formats.android.AndroidXmlNode +import io.tolgee.formats.android.PluralUnit +import io.tolgee.formats.android.StringArrayUnit +import io.tolgee.formats.android.StringUnit +import io.tolgee.util.appendXmlOrText +import io.tolgee.util.attr +import io.tolgee.util.buildDom +import io.tolgee.util.element +import org.w3c.dom.Element +import java.io.InputStream + +class AndroidStringsXmlFileWriter(private val model: AndroidStringsXmlModel, private val enableXmlContent: Boolean) { + fun produceFiles(): InputStream { + return buildDom { + element("resources") { + attr("xmlns:xliff", "urn:oasis:names:tc:xliff:document:1.2") + model.items.forEach { this.addToElement(it) } + } + }.write().toByteArray().inputStream() + } + + private fun Element.addToElement(unit: Map.Entry) { + when (val node = unit.value) { + is StringUnit -> { + element("string") { + attr("name", unit.key) + appendXmlIfEnabledOrText((unit.value as StringUnit).value) + } + } + + is StringArrayUnit -> { + element("string-array") { + attr("name", unit.key) + node.items.sortedBy { it.index }.forEach { + element("item") { + appendXmlIfEnabledOrText(it.value) + } + } + } + } + + is PluralUnit -> { + element("plurals") { + attr("name", unit.key) + node.items.forEach { + element("item") { + attr("quantity", it.key) + appendXmlIfEnabledOrText(it.value) + } + } + } + } + } + } + + private fun Element.appendXmlIfEnabledOrText(content: String?) { + if (!enableXmlContent) { + textContent = content + return + } + this.appendXmlOrText(content) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/out/IcuToJavaMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/IcuToJavaMessageConvertor.kt new file mode 100644 index 0000000000..33d474b0a9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/IcuToJavaMessageConvertor.kt @@ -0,0 +1,16 @@ +package io.tolgee.formats.android.out + +import io.tolgee.formats.MessageConvertorFactory +import io.tolgee.formats.PossiblePluralConversionResult + +class IcuToJavaMessageConvertor( + private val message: String, + private val forceIsPlural: Boolean? = null, + private val isProjectIcuPlaceholdersEnabled: Boolean, +) { + fun convert(): PossiblePluralConversionResult { + return MessageConvertorFactory(message, forceIsPlural, isProjectIcuPlaceholdersEnabled) { + JavaFromIcuParamConvertor() + }.create().convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/android/out/JavaFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/JavaFromIcuParamConvertor.kt new file mode 100644 index 0000000000..57e39619c5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/android/out/JavaFromIcuParamConvertor.kt @@ -0,0 +1,75 @@ +package io.tolgee.formats.android.out + +import com.ibm.icu.text.MessagePattern +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class JavaFromIcuParamConvertor : FromIcuParamConvertor { + private var argIndex = -1 + private var wasNumberedArg = false + + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + argIndex++ + val argNum = node.name?.toIntOrNull() + val argNumString = getArgNumString(argNum) + val type = node.argType + + if (type == MessagePattern.ArgType.SIMPLE) { + when (node.typeName) { + "number" -> return convertNumber(node, argNum) + } + } + + if (type == MessagePattern.ArgType.NONE) { + return "%${argNumString}s" + } + + return node.toString() + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return "%d" + } + + private fun convertNumber( + node: MessagePatternUtil.ArgNode, + argNum: Int?, + ): String { + if (node.simpleStyle?.trim() == "scientific") { + return "%${getArgNumString(argNum)}e" + } + val precision = getPrecision(node) + if (precision == 6) { + return "%${getArgNumString(argNum)}f" + } + if (precision != null) { + return "%${getArgNumString(argNum)}.${precision}f" + } + + return "%${getArgNumString(argNum)}d" + } + + private fun getPrecision(node: MessagePatternUtil.ArgNode): Int? { + val precisionMatch = ICU_PRECISION_REGEX.matchEntire(node.simpleStyle ?: "") + precisionMatch ?: return null + return precisionMatch.groups["precision"]?.value?.length + } + + private fun getArgNumString(icuArgNum: Int?): String { + if ((icuArgNum != argIndex || wasNumberedArg) && icuArgNum != null) { + wasNumberedArg = true + return "${icuArgNum + 1}$" + } + return "" + } + + companion object { + val ICU_PRECISION_REGEX = """.*\.(?0+)""".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/appleFormatConstants.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/appleFormatConstants.kt new file mode 100644 index 0000000000..f7e92e1995 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/appleFormatConstants.kt @@ -0,0 +1,5 @@ +package io.tolgee.formats.apple + +const val APPLE_FILE_ORIGINAL_CUSTOM_KEY = "_appleXliffFileOriginal" +const val APPLE_PLURAL_PROPERTY_CUSTOM_KEY = "_appleXliffPropertyName" +const val APPLE_CORRESPONDING_STRINGS_FILE_ORIGINAL = "_appleXliffStringsFileOriginal" diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleCollisionHandler.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleCollisionHandler.kt new file mode 100644 index 0000000000..51bb41c7da --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleCollisionHandler.kt @@ -0,0 +1,36 @@ +package io.tolgee.formats.apple.`in` + +import io.tolgee.formats.CollisionHandler +import io.tolgee.model.dataImport.ImportTranslation +import org.springframework.stereotype.Component + +@Component +class AppleCollisionHandler : CollisionHandler { + /** + * Takes the colliding translations and returns the ones to be deleted + */ + override fun handle(importTranslations: List): List? { + val strings = + importTranslations + .filter { it.key.file.name?.matches(STRINGS_FILE_REGEX) == true } + + val stringsDict = + importTranslations + .filter { it.key.file.name?.matches(STRINGSDICT_FILE_REGEX) == true } + + if (strings.isEmpty() || stringsDict.isEmpty()) { + return null + } + + if (importTranslations.size - strings.size == 1) { + return strings + } + + return null + } + + companion object { + val STRINGS_FILE_REGEX by lazy { ".*\\.strings".toRegex() } + val STRINGSDICT_FILE_REGEX by lazy { ".*\\.stringsdict".toRegex() } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuMessageConvertor.kt new file mode 100644 index 0000000000..62b8e8b0ea --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuMessageConvertor.kt @@ -0,0 +1,58 @@ +package io.tolgee.formats.apple.`in` + +import io.tolgee.formats.ImportMessageConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.convertMessage +import io.tolgee.formats.toIcuPluralString + +class AppleToIcuMessageConvertor : ImportMessageConvertor { + @Suppress("UNCHECKED_CAST") + override fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + val stringValue = rawData as? String ?: (rawData as? Map<*, *>)?.get("_stringValue") as? String + + if (stringValue is String) { + val converted = convert(stringValue, false, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, false) + } + + if (rawData is Map<*, *>) { + val rawDataMap = rawData as Map + val converted = convertPlural(rawDataMap, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, true) + } + + if (rawData == null) { + return MessageConvertorResult(null, false) + } + + throw IllegalArgumentException("Unsupported type of message") + } + + private fun convertPlural( + rawData: Map, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + val converted = + rawData.mapNotNull { + it.key to convert(it.value, true, convertPlaceholders, isProjectIcuEnabled) + }.toMap().toIcuPluralString(optimize = true, addNewLines = true, argName = "0") + return converted + } + + private fun convert( + message: String, + isInPlural: Boolean = false, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + return convertMessage(message, isInPlural, convertPlaceholders, isProjectIcuEnabled) { + AppleToIcuParamConvertor() + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuParamConvertor.kt new file mode 100644 index 0000000000..72e4f2e6d0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/AppleToIcuParamConvertor.kt @@ -0,0 +1,68 @@ +package io.tolgee.formats.apple.`in` + +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertFloatToIcu +import io.tolgee.formats.escapeIcu +import io.tolgee.formats.po.`in`.CLikeParameterParser +import io.tolgee.formats.usesUnsupportedFeature + +class AppleToIcuParamConvertor() : ToIcuParamConvertor { + private val parser = CLikeParameterParser() + private var index = 0 + + override val regex: Regex + get() = REGEX + + override fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String { + val parsed = parser.parse(matchResult) ?: return matchResult.value.escapeIcu(isInPlural) + if (parsed.specifier == "%") { + return "%" + } + + index++ + val zeroIndexedArgNum = parsed.argNum?.toIntOrNull()?.minus(1) + val name = zeroIndexedArgNum?.toString() ?: ((index - 1).toString()) + val isLld = parsed.length == "ll" && parsed.specifier == "d" && name == "0" + + if (isInPlural && isLld) { + val isFirstParam = zeroIndexedArgNum == 0 || index == 1 + if (isFirstParam) { + return "#" + } + } + + if (isLld) { + return "{$name, number}" + } + + if (usesUnsupportedFeature(parsed)) { + return parsed.fullMatch.escapeIcu(isInPlural) + } + + when (parsed.specifier) { + "@" -> return "{$name}" + "e" -> return "{$name, number, scientific}" + "f" -> return convertFloatToIcu(parsed, name) ?: parsed.fullMatch.escapeIcu(isInPlural) + } + + return parsed.fullMatch.escapeIcu(isInPlural) + } + + companion object { + val REGEX = + """ + (?x)( + % + (?:(?\d+)${"\\$"})? + (?[-+\s0\#]+)? + (?\d+)? + (?:\.(?\d+))? + (?ll|hh|h|l|z|j|t|L)? + (?@\w+@|[diuoxXfFeEgGaAcspn@l%]) + ) + """.trimIndent().toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/langGuessUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/langGuessUtil.kt new file mode 100644 index 0000000000..ed303ca1c3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/langGuessUtil.kt @@ -0,0 +1,5 @@ +package io.tolgee.formats.apple.`in` + +fun guessLanguageFromPath(filePath: String): String { + return filePath.split("/").find { it.endsWith(".lproj") }?.removeSuffix(".lproj") ?: "unknown" +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/namespaceGuessUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/namespaceGuessUtil.kt new file mode 100644 index 0000000000..e91c29924a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/namespaceGuessUtil.kt @@ -0,0 +1,12 @@ +package io.tolgee.formats.apple.`in` + +fun guessNamespaceFromPath(filePath: String): String { + val guessed = REGEX.find(filePath)?.groups?.get("namespace")?.value ?: return "" + // "Localizable" is default tableName in Apple suite + if (guessed == "Localizable") { + return "" + } + return guessed +} + +val REGEX = "(?:[\\w-]+\\.lproj/)?(?[\\w-.&#\$@{}*^~\\s]+)\\.strings(?:dict)?".toRegex() diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/stringdict/StringsdictFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/stringdict/StringsdictFileProcessor.kt new file mode 100644 index 0000000000..2a652e284c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/stringdict/StringsdictFileProcessor.kt @@ -0,0 +1,166 @@ +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.apple.`in`.AppleToIcuMessageConvertor +import io.tolgee.formats.apple.`in`.guessLanguageFromPath +import io.tolgee.formats.apple.`in`.guessNamespaceFromPath +import io.tolgee.service.dataImport.processors.FileProcessorContext +import javax.xml.stream.XMLInputFactory +import javax.xml.stream.events.StartElement + +open class StringsdictFileProcessor( + override val context: FileProcessorContext, +) : ImportFileProcessor() { + enum class ParseState { + Initial, + InitialDict, + Format, + FormatValues, + TranslationKey, + FormatValueTypeKey, + PluralForm, + } + + var state = ParseState.Initial + private var translationKey: String = "" + + private val xmlInputFactory = XMLInputFactory.newInstance() + private val eventReader = xmlInputFactory.createXMLEventReader(context.file.data.inputStream()) + private var formatValueTypeKey = "li" + private val forms = mutableMapOf() + private var pluralForm = "other" + + override fun process() { + try { + while (eventReader.hasNext()) { + val event = eventReader.nextEvent() + + if (event.isStartElement) { + handleStartElement(event.asStartElement()) + } else if (event.isEndElement) { + handleEndElement(event.asEndElement().name.localPart) + } + } + + eventReader.close() + } catch (e: Exception) { + throw ImportCannotParseFileException(context.file.name, e.message) + } + context.namespace = guessNamespaceFromPath(context.file.name) + } + + private fun handleStartElement(startElement: StartElement) { + when (startElement.name.localPart) { + "dict" -> handleDictStartTag() + "key" -> handleKeyTag(eventReader.nextEvent().asCharacters().data) + "string" -> handleStringTag(eventReader.nextEvent().asCharacters().data) + "plist", "version" -> { // Ignore these tags + } + + else -> { + throw ImportCannotParseFileException(context.file.name, "unexpected element: <${startElement.name.localPart}>") + } + } + } + + private fun handleDictStartTag() { + state = + when (state) { + ParseState.Initial -> ParseState.InitialDict + ParseState.TranslationKey -> ParseState.Format + ParseState.Format -> ParseState.FormatValues + else -> { + throw ImportCannotParseFileException(context.file.name, "unexpected element in state: $state") + } + } + } + + private fun handleKeyTag(tagValue: String) { + when (state) { + ParseState.InitialDict -> { + translationKey = tagValue + state = ParseState.TranslationKey + } + + ParseState.FormatValues -> { + when (tagValue) { + "NSStringFormatValueTypeKey" -> state = ParseState.FormatValueTypeKey + "NSStringFormatSpecTypeKey" -> {} + else -> { + state = ParseState.PluralForm + pluralForm = tagValue + } + } + } + + else -> { // Do nothing + } + } + } + + private fun handleStringTag(value: String) { + if (state == ParseState.FormatValueTypeKey) { + formatValueTypeKey = value + state = ParseState.FormatValues + return + } + + if (state == ParseState.PluralForm) { + forms[pluralForm] = value + state = ParseState.FormatValues + return + } + } + + private fun handleEndElement(endElement: String) { + when (endElement) { + "dict" -> handleDictEndTag() + else -> { // Do nothing + } + } + } + + private fun handleDictEndTag() { + when (state) { + ParseState.FormatValues -> { + addTranslation() + state = ParseState.Format + } + + ParseState.Format -> { + translationKey = "" + state = ParseState.InitialDict + } + + ParseState.InitialDict -> { + state = ParseState.Initial + } + + else -> { + // do nothing + } + } + } + + private val languageName: String by lazy { + guessLanguageFromPath(context.file.name) + } + + private fun addTranslation() { + val translation = + AppleToIcuMessageConvertor().convert( + forms, + languageName, + context.importSettings.convertPlaceholdersToIcu, + context.projectIcuPlaceholdersEnabled, + ).message + context.addTranslation( + translationKey, + languageName, + translation, + forceIsPlural = true, + rawData = forms, + convertedBy = ImportMessageConvertorType.STRINGSDICT, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/strings/StringsFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/strings/StringsFileProcessor.kt new file mode 100644 index 0000000000..87a1de76db --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/strings/StringsFileProcessor.kt @@ -0,0 +1,139 @@ +package io.tolgee.formats.apple.`in`.strings + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.StringWrapper +import io.tolgee.formats.apple.`in`.guessLanguageFromPath +import io.tolgee.formats.apple.`in`.guessNamespaceFromPath +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class StringsFileProcessor( + override val context: FileProcessorContext, +) : ImportFileProcessor() { + private var state = State.OUTSIDE + private var key: String? = null + private var value: String? = null + private var wasLastCharEscape = false + private var currentComment: StringBuilder? = null + private var lastChar: Char? = null + + override fun process() { + parseFileToContext() + context.namespace = guessNamespaceFromPath(context.file.name) + } + + private fun parseFileToContext() { + context.file.data.decodeToString().forEachIndexed { index, char -> + if (!wasLastCharEscape && char == '\\') { + wasLastCharEscape = true + return@forEachIndexed + } + + when (state) { + State.OUTSIDE -> { + if (char == '\"') { + state = State.INSIDE_KEY + key = "" + } + + if (char == '=') { + if (key == null) { + throw ImportCannotParseFileException(context.file.name, "Unexpected '=' character on position $index") + } + + state = State.EXPECT_VALUE + } + + if (char == '/' && lastChar == '/') { + currentComment = null + state = State.INSIDE_INLINE_COMMENT + } + + if (lastChar == '/' && char == '*') { + currentComment = null + state = State.INSIDE_BLOCK_COMMENT + } + } + + State.EXPECT_VALUE -> { + if (char == '\"') { + state = State.INSIDE_VALUE + value = "" + } + } + + State.INSIDE_KEY -> { + if (char == '\"' && !wasLastCharEscape) { + state = State.OUTSIDE + } else { + key += char + } + } + + State.INSIDE_VALUE -> { + if (char == '\"' && !wasLastCharEscape) { + state = State.OUTSIDE + onPairParsed() + key = null + value = null + } else { + value += char + } + } + + State.INSIDE_INLINE_COMMENT -> { + // inline comment is ignored + if (char == '\n' && !wasLastCharEscape) { + state = State.OUTSIDE + } + } + + State.INSIDE_BLOCK_COMMENT -> { + if (lastChar == '*' && char == '/' && !wasLastCharEscape) { + currentComment?.let { + it.deleteCharAt(it.length - 1) + } + state = State.OUTSIDE + } else { + currentComment = (currentComment ?: StringBuilder()).also { it.append(char) } + } + } + } + lastChar = char + wasLastCharEscape = false + } + } + + private fun onPairParsed() { + val converted = + ImportMessageConvertorType.STRINGS.importMessageConvertor!!.convert( + rawData = value, + languageTag = languageName, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled, + ).message + context.addKeyDescription(key ?: return, currentComment?.toString()) + context.addTranslation( + key ?: return, + languageName, + converted, + rawData = StringWrapper(value), + convertedBy = ImportMessageConvertorType.STRINGS, + ) + currentComment = null + } + + private val languageName: String by lazy { + guessLanguageFromPath(context.file.name) + } + + enum class State { + OUTSIDE, + INSIDE_INLINE_COMMENT, + INSIDE_BLOCK_COMMENT, + INSIDE_KEY, + INSIDE_VALUE, + EXPECT_VALUE, + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/xliff/AppleXliffFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/xliff/AppleXliffFileProcessor.kt new file mode 100644 index 0000000000..301d99e3fe --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/in/xliff/AppleXliffFileProcessor.kt @@ -0,0 +1,223 @@ +package io.tolgee.formats.apple.`in`.xliff + +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.StringWrapper +import io.tolgee.formats.apple.APPLE_CORRESPONDING_STRINGS_FILE_ORIGINAL +import io.tolgee.formats.apple.APPLE_FILE_ORIGINAL_CUSTOM_KEY +import io.tolgee.formats.apple.APPLE_PLURAL_PROPERTY_CUSTOM_KEY +import io.tolgee.formats.xliff.model.XliffFile +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.formats.xliff.model.XliffTransUnit +import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType +import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class AppleXliffFileProcessor(override val context: FileProcessorContext, private val parsed: XliffModel) : + ImportFileProcessor() { + /** + * file -> Map (KeyName -> Map (Form -> Pair (Source, Target ))) + */ + private val allPlurals = mutableMapOf>>>() + + override fun process() { + // for apple xliff, we currently don't support namespaces + handleNamespace() + parsed.files.forEach { file -> + file.transUnits.forEach transUnitsForeach@{ transUnit -> + // if there is a key defined in base .stringsdict but is missing in the target .stringsdict + // it adds the key also in the .strings file section in xliff, and sets the "translate" value to "no" + // in the same time, the key is present in the .stringsdict section. This led to the target translation was + // imported 2 times, and ignored by the import process + if (transUnit.translate == "no") { + return@transUnitsForeach + } + + val fileOriginal = file.original + val transUnitId = + transUnit.id ?: let { + context.fileEntity.addIssue( + FileIssueType.ID_ATTRIBUTE_NOT_PROVIDED, + mapOf(FileIssueParamType.FILE_NODE_ORIGINAL to (fileOriginal ?: "")), + ) + return@transUnitsForeach + } + + addNote(transUnit, transUnitId) + + val (pluralFormRegex, pluralDefRegex) = getPluralRegexes(file) + + val pluralFormMatch = pluralFormRegex.matchEntire(transUnitId) + if (pluralFormMatch != null) { + handlePlural(pluralFormMatch, file, transUnit) + return@transUnitsForeach + } + + if (pluralDefRegex != null && transUnitId.matches(pluralDefRegex)) { + // let's ignore this one, since it has no meaning for us now + return@transUnitsForeach + } + + handleSingle(fileOriginal, transUnitId, transUnit, file) + } + } + + handlePlurals() + } + + private fun handleNamespace() { + context.namespace = "" + } + + private fun getPluralRegexes(file: XliffFile): Pair { + if (file.original?.contains(".stringsdict") == true) { + return STRINGSDICT_PLURAL_FORM_REGEX to STRINGSDICT_PLURAL_DEF_REGEX + } + + return XCSTRINGS_PLURAL_FORM_REGEX to null + } + + private fun addNote( + transUnit: XliffTransUnit, + transUnitId: String, + ) { + context.addKeyDescription(transUnitId, transUnit.note) + } + + private fun handleSingle( + fileOriginal: String?, + transUnitId: String, + transUnit: XliffTransUnit, + file: XliffFile, + ) { + if (!fileOriginal.isNullOrBlank()) { + context.setCustom(transUnitId, APPLE_FILE_ORIGINAL_CUSTOM_KEY, fileOriginal) + } + + addTranslations(transUnit, transUnitId, file) + } + + private fun handlePlural( + pluralFormMatch: MatchResult, + file: XliffFile, + transUnit: XliffTransUnit, + ) { + val keyName = pluralFormMatch.groups["keyname"]?.value ?: return + + assignPropertyName(keyName, pluralFormMatch) + + val pluralFile = + allPlurals.computeIfAbsent(file) { mutableMapOf() } + + pluralFile.compute(keyName) { _, map -> + val formMap = map ?: mutableMapOf() + val form = pluralFormMatch.groups["form"]?.value ?: return@compute formMap + formMap[form] = transUnit.source to transUnit.target + formMap + } + } + + private fun assignPropertyName( + keyName: String, + pluralFormMatch: MatchResult, + ) { + try { + val propertyName = pluralFormMatch.groups["property"]?.value ?: return + context.setCustom(keyName, APPLE_PLURAL_PROPERTY_CUSTOM_KEY, propertyName) + } catch (e: IllegalArgumentException) { + // the property group is optional, so it might throw + } + } + + /** + * The plurals have to be handled last. We need to replace te non-plural translations, because + * the plural keys appear in the .strings file section in xliff if missing in localizad .stringsdict file + * if the non-plural keys were not removed from the .strings section it would show false positive file issues + * (key defined multiple times in the same file) + */ + private fun handlePlurals() { + allPlurals.forEach { (file, pluralsUnits) -> + pluralsUnits.forEach { (keyName, forms) -> + context.keys[keyName]?.keyMeta?.custom?.get(APPLE_FILE_ORIGINAL_CUSTOM_KEY)?.let { + // when importing the xliff apple requires us to store it exactly to the same file original attribute + // the issue is that the files have to be stored in different paths, + // and so we need to remember the value when importing + context.setCustom(keyName, APPLE_CORRESPONDING_STRINGS_FILE_ORIGINAL, it) + } + context.setCustom(keyName, APPLE_FILE_ORIGINAL_CUSTOM_KEY, file.original ?: "") + val sourceForms = forms.mapValues { it.value.first } + val targetForms = forms.mapValues { it.value.second } + addPluralTranslation(keyName, sourceForms, file.sourceLanguage ?: "unknown source") + addPluralTranslation(keyName, targetForms, file.targetLanguage ?: "unknown target") + } + } + } + + private fun addPluralTranslation( + keyName: String, + forms: Map, + language: String, + ) { + if (forms.containsKey("other") && forms["other"] != null) { + val converted = + ImportMessageConvertorType.APPLE_XLIFF.importMessageConvertor!!.convert( + rawData = forms, + languageTag = language, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled, + ).message + context.addTranslation( + keyName, + language, + converted, + replaceNonPlurals = true, + convertedBy = ImportMessageConvertorType.APPLE_XLIFF, + rawData = forms, + ) + } + } + + private fun addTranslations( + transUnit: XliffTransUnit, + transUnitId: String, + file: XliffFile, + ) { + transUnit.source?.let { source -> + context.addTranslation( + transUnitId, + file.sourceLanguage ?: "unknown source", + convertMessage(source), + rawData = StringWrapper(source), + convertedBy = ImportMessageConvertorType.APPLE_XLIFF, + ) + } + + transUnit.target?.let { target -> + context.addTranslation( + transUnitId, + file.targetLanguage ?: "unknown target", + convertMessage(target), + rawData = StringWrapper(target), + convertedBy = ImportMessageConvertorType.APPLE_XLIFF, + ) + } + } + + private fun convertMessage(message: String): String? { + return ImportMessageConvertorType.APPLE_XLIFF.importMessageConvertor!!.convert( + message, + "who-knows", + context.importSettings.convertPlaceholdersToIcu, + context.projectIcuPlaceholdersEnabled, + ).message + } + + companion object { + private val XCSTRINGS_PLURAL_FORM_REGEX = + "^/?(?[^:]+)\\|==\\|plural\\.(?
[a-z0-9=]+)\$".toRegex() + private val STRINGSDICT_PLURAL_DEF_REGEX = + "^/?(?[^:]+):dict/(?[^:]+):dict/:string$".toRegex() + private val STRINGSDICT_PLURAL_FORM_REGEX = + "^/?(?[^:]+):dict/(?[^:]+):dict/(?[a-z0-9=]+):dict/:string\$".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleFromIcuParamConvertor.kt new file mode 100644 index 0000000000..a3d1658473 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleFromIcuParamConvertor.kt @@ -0,0 +1,75 @@ +package io.tolgee.formats.apple.out + +import com.ibm.icu.text.MessagePattern +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class AppleFromIcuParamConvertor : FromIcuParamConvertor { + private var argIndex = -1 + private var wasNumberedArg = false + + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + argIndex++ + val argNum = node.name?.toIntOrNull() + val argNumString = getArgNumString(argNum) + val type = node.argType + + if (type == MessagePattern.ArgType.SIMPLE) { + when (node.typeName) { + "number" -> return convertNumber(node, argNum) + } + } + + if (type == MessagePattern.ArgType.NONE) { + return "%$argNumString@" + } + + return node.toString() + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return "%lld" + } + + private fun convertNumber( + node: MessagePatternUtil.ArgNode, + argNum: Int?, + ): String { + if (node.simpleStyle?.trim() == "scientific") { + return "%${getArgNumString(argNum)}e" + } + val precision = getPrecision(node) + if (precision == 6) { + return "%${getArgNumString(argNum)}f" + } + if (precision != null) { + return "%${getArgNumString(argNum)}.${precision}f" + } + + return "%${getArgNumString(argNum)}lld" + } + + private fun getPrecision(node: MessagePatternUtil.ArgNode): Int? { + val precisionMatch = ICU_PRECISION_REGEX.matchEntire(node.simpleStyle ?: "") + precisionMatch ?: return null + return precisionMatch.groups["precision"]?.value?.length + } + + private fun getArgNumString(icuArgNum: Int?): String { + if ((icuArgNum != argIndex || wasNumberedArg) && icuArgNum != null) { + wasNumberedArg = true + return "${icuArgNum + 1}$" + } + return "" + } + + companion object { + val ICU_PRECISION_REGEX = """.*\.(?0+)""".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleStringsStringsdictExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleStringsStringsdictExporter.kt new file mode 100644 index 0000000000..1ac30fc513 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleStringsStringsdictExporter.kt @@ -0,0 +1,92 @@ +package io.tolgee.formats.apple.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class AppleStringsStringsdictExporter( + override val translations: List, + override val exportParams: IExportParams, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + override val fileExtension: String = "" + + private val preparedFiles = mutableMapOf() + + override fun produceFiles(): Map { + translations.forEach { + handleTranslation(it) + } + + val result = mutableMapOf() + preparedFiles.forEach { + if (it.value.hasSingle) { + result["${it.key}strings"] = it.value.stringsWriter.result.byteInputStream() + } + if (it.value.hasPlural) { + result["${it.key}stringsdict"] = it.value.stringsdictWriter.result + } + } + return result + } + + private fun getBaseFilePath(translation: ExportTranslationView): String { + val namespace = translation.key.namespace ?: "" + val filePath = "${translation.languageTag}.lproj/Localizable." + return "$namespace/$filePath".replace("^/".toRegex(), "") + } + + private fun handleTranslation(it: ExportTranslationView) { + val text = it.text + + if (text == null) { + handleSingle(it, "") + return + } + + val converted = + IcuToAppleMessageConvertor(message = text, it.key.isPlural, isProjectIcuPlaceholdersEnabled).convert() + + if (converted.isPlural()) { + handlePlural(it, converted.formsResult ?: return) + return + } + + handleSingle(it, converted.singleResult ?: return) + } + + private fun handlePlural( + translationView: ExportTranslationView, + formsResult: Map, + ) { + val preparedFile = getResultPreparedFile(translationView) + preparedFile.stringsdictWriter.addEntry(translationView.key.name, formsResult) + } + + private fun handleSingle( + rawData: ExportTranslationView, + convertedText: String, + ) { + val preparedFile = getResultPreparedFile(rawData) + preparedFile.stringsWriter.addEntry(rawData.key.name, convertedText, rawData.key.description) + } + + private fun getResultPreparedFile(translation: ExportTranslationView): PreparedFile { + return preparedFiles.getOrPut(getBaseFilePath(translation)) { PreparedFile() } + } + + class PreparedFile { + var hasPlural = false + val stringsdictWriter: StringsdictWriter by lazy { + hasPlural = true + StringsdictWriter() + } + + var hasSingle = false + val stringsWriter: StringsWriter by lazy { + hasSingle = true + StringsWriter() + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt new file mode 100644 index 0000000000..1f0b85e1b5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffExporter.kt @@ -0,0 +1,302 @@ +package io.tolgee.formats.apple.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.PossiblePluralConversionResult +import io.tolgee.formats.apple.APPLE_CORRESPONDING_STRINGS_FILE_ORIGINAL +import io.tolgee.formats.apple.APPLE_FILE_ORIGINAL_CUSTOM_KEY +import io.tolgee.formats.apple.APPLE_PLURAL_PROPERTY_CUSTOM_KEY +import io.tolgee.formats.xliff.model.XliffFile +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.formats.xliff.model.XliffTransUnit +import io.tolgee.formats.xliff.out.XliffFileWriter +import io.tolgee.service.export.dataProvider.ExportKeyView +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class AppleXliffExporter( + override val translations: List, + override val exportParams: IExportParams, + baseTranslationsProvider: () -> List, + private val baseLanguageTag: String, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + override val fileExtension: String = ExportFormat.XLIFF.extension + + private val baseTranslations by lazy { + baseTranslationsProvider().associateBy { it.key.namespace to it.key.name } + } + + /** + * Map (keyName -> Map (form -> value) ) + */ + private val convertedSources = mutableMapOf() + + /** + * Map (keyName -> Map (form -> value) ) + */ + private lateinit var allLanguages: Set + + /** + * Path -> Xliff Model + */ + val models = mutableMapOf() + + private val allStringsDict = mutableMapOf() + + override fun produceFiles(): Map { + prepare() + return models.asSequence().map { (fileName, resultItem) -> + fileName to XliffFileWriter(xliffModel = resultItem, enableXmlContent = false).produceFiles() + }.toMap() + } + + private fun prepare() { + allLanguages = translations.map { it.languageTag }.toSet() + addTargetTranslations() + addSourceTranslations() + addAllFromStringsdictToStrings() + } + + private fun addAllFromStringsdictToStrings() { + models.values.forEach { model -> + model.files.filter { it.getFileType == FileType.STRINGSDICT }.forEach filesForeach@{ stringsdictFile -> + val targetLanguage = stringsdictFile.targetLanguage ?: return@filesForeach + + allStringsDict.forEach allStringsDictForeach@{ keyInStringsdict -> + getFileByTargetFileOriginal( + xliffModel = model, + targetFileOriginal = keyInStringsdict.value.correspondingFileOriginal, + languageTag = targetLanguage, + ).createTransUnitIfMissing(keyInStringsdict.key).apply { + this.source = keyInStringsdict.key + this.note = keyInStringsdict.value.note + } + } + } + } + } + + private fun addSourceTranslations() { + allLanguages.forEach { targetLanguageTag -> + baseTranslations.forEach { + addSourceTranslation(it.value, targetLanguageTag) + } + } + } + + private fun addTargetTranslations() { + translations.forEach { translation -> + addTargetTranslation(translation) + } + } + + private fun addTargetTranslation(translation: ExportTranslationView) { + val converted = + translation.text?.let { + IcuToAppleMessageConvertor( + message = it, + translation.key.isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + } + + if (converted?.isPlural() == true) { + handlePlural(translation, converted) + return + } + return handleSingle(translation, converted?.singleResult, false) + } + + private fun addSourceTranslation( + translation: ExportTranslationView, + targetLanguageTag: String, + ) { + val converted = + convertedSources.computeIfAbsent(translation.key.name) { + translation.text?.let { + IcuToAppleMessageConvertor( + message = it, + forceIsPlural = translation.key.isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + } + } + + if (converted?.isPlural() == true) { + handlePlural(translation, converted, targetLanguageTag, true) + return + } + return handleSingle(translation, converted?.singleResult, true, targetLanguageTag = targetLanguageTag) + } + + private fun handleSingle( + translation: ExportTranslationView, + value: String?, + isSource: Boolean, + targetLanguageTag: String = translation.languageTag, + ) { + getResultXliffFile(targetLanguageTag, translation.key, isPlural = false) + .createTransUnitIfMissing(translation.key.name).apply { + note = translation.key.description + setValue(isSource, value) + } + } + + private fun handlePlural( + translation: ExportTranslationView, + converted: PossiblePluralConversionResult?, + targetLanguageTag: String = translation.languageTag, + isSource: Boolean = false, + ) { + val resultFile = getResultXliffFile(targetLanguageTag, key = translation.key, isPlural = true) + + val property = translation.key.custom?.get(APPLE_PLURAL_PROPERTY_CUSTOM_KEY) as? String ?: "property" + + val fileType = resultFile.getFileType ?: FileType.STRINGSDICT + if (fileType == FileType.STRINGSDICT) { + addToAllStringsdictKeys(translation) + resultFile.createTransUnitIfMissing( + "/${translation.key.name}:dict/NSStringLocalizedFormatKey:dict/:string", + ).apply { + setValue(isSource, "%#@$property@") + } + } + + val pluralFormVariants = populateForms(targetLanguageTag, converted) + + pluralFormVariants.keys.forEach { keyword -> + resultFile.createTransUnitIfMissing( + id = getPluralTransUnitId(translation.key.name, property, keyword, fileType), + ).apply { + val result = pluralFormVariants[keyword] ?: pluralFormVariants["other"] + setValue(isSource, result) + } + } + } + + private fun addToAllStringsdictKeys(translation: ExportTranslationView) { + // when importing the xliff apple requires us to store it exactly to the same file original attribute + // the issue is that the files have to be stored in different paths, + // and so we need to remember the value when importing + val correspondingStringsFileOriginal = + translation.key.custom?.get(APPLE_CORRESPONDING_STRINGS_FILE_ORIGINAL) as String? + if (correspondingStringsFileOriginal != null) { + allStringsDict[translation.key.name] = + KeyInStringsDict( + key = translation.key.name, + note = translation.key.description, + correspondingFileOriginal = correspondingStringsFileOriginal, + ) + } + } + + private fun XliffTransUnit.setValue( + isSource: Boolean, + result: String?, + ) { + if (isSource) { + source = result ?: "" + return + } + this.target = result + } + + private fun XliffFile.createTransUnitIfMissing(id: String): XliffTransUnit { + return this.transUnits.find { it.id == id } ?: XliffTransUnit().apply { + this.id = id + this@createTransUnitIfMissing.transUnits.add(this) + } + } + + private fun getPluralTransUnitId( + keyName: String, + property: String, + keyword: String, + fileType: FileType, + ): String { + return when (fileType) { + FileType.XCSTRINGS -> return "$keyName|==|plural.$keyword" + FileType.STRINGSDICT -> "/$keyName:dict/$property:dict/$keyword:dict/:string" + } + } + + private fun populateForms( + languageTag: String, + conversionResult: PossiblePluralConversionResult?, + ): Map { + if (conversionResult?.formsResult == null) { + return emptyMap() + } + return io.tolgee.formats.populateForms(languageTag, conversionResult.formsResult) + } + + private fun getResultXliffFile( + languageTag: String, + key: ExportKeyView, + isPlural: Boolean, + ): XliffFile { + val absolutePath = getFilePath(languageTag, key.namespace) + val xliffModel = + models.computeIfAbsent(absolutePath) { + XliffModel() + } + + val targetFileOriginal = + key.custom?.get(APPLE_FILE_ORIGINAL_CUSTOM_KEY) as? String ?: let { + val filename = key.namespace ?: "Localizable" + val extension = if (isPlural) "stringsdict" else "strings" + "$filename.$extension" + } + + return getFileByTargetFileOriginal(xliffModel, targetFileOriginal, languageTag) + } + + private fun getFileByTargetFileOriginal( + xliffModel: XliffModel, + targetFileOriginal: String, + languageTag: String, + ) = xliffModel.files.find { it.original == targetFileOriginal } ?: let { + val file = + XliffFile().apply { + this.original = targetFileOriginal + this.sourceLanguage = baseLanguageTag + this.targetLanguage = languageTag + } + xliffModel.files.add(file) + file + } + + private val XliffFile.getFileType: FileType? + get() { + if (this.original?.endsWith(".stringsdict") == true) { + return FileType.STRINGSDICT + } else if (this.original?.endsWith(".xcstrings") == true) { + return FileType.XCSTRINGS + } + return null + } + + enum class FileType { + XCSTRINGS, + STRINGSDICT, + } + + fun getFilePath( + languageTag: String, + namespace: String?, + ): String { + return "$languageTag.$fileExtension" + } + + companion object { + val STRINGSDICT_REGEX = Regex("\\.stringsdict$") + } + + data class KeyInStringsDict( + val key: String, + val note: String?, + val correspondingFileOriginal: String, + ) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffTransUnitInfo.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffTransUnitInfo.kt new file mode 100644 index 0000000000..2e5e09526d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/AppleXliffTransUnitInfo.kt @@ -0,0 +1,5 @@ +package io.tolgee.formats.apple.out + +class AppleXliffTransUnitInfo( + val pluralFormKeyword: String, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/IcuToAppleMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/IcuToAppleMessageConvertor.kt new file mode 100644 index 0000000000..d4489dbcd5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/IcuToAppleMessageConvertor.kt @@ -0,0 +1,16 @@ +package io.tolgee.formats.apple.out + +import io.tolgee.formats.MessageConvertorFactory +import io.tolgee.formats.PossiblePluralConversionResult + +class IcuToAppleMessageConvertor( + private val message: String, + private val forceIsPlural: Boolean?, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) { + fun convert(): PossiblePluralConversionResult { + return MessageConvertorFactory(message, forceIsPlural, isProjectIcuPlaceholdersEnabled) { + AppleFromIcuParamConvertor() + }.create().convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsWriter.kt new file mode 100644 index 0000000000..d3942ae0b4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsWriter.kt @@ -0,0 +1,30 @@ +package io.tolgee.formats.apple.out + +class StringsWriter { + private val content = StringBuilder() + + fun addEntry( + key: String, + value: String, + comment: String? = null, + ) { + comment?.let { + val escaped = escapeComment(it) + content.append("/* $escaped */\n") + } + content.append("\"${escaped(key)}\" = \"${escaped(value)}\";\n\n") + } + + private fun escapeComment(s: String): String { + return s.replace("*/", "*\\/") + } + + private fun escaped(string: String): String { + return string + .replace("\\", "\\\\") + .replace("\"", "\\\"") + } + + val result: String + get() = content.toString() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsdictWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsdictWriter.kt new file mode 100644 index 0000000000..5dac5f6eb9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/apple/out/StringsdictWriter.kt @@ -0,0 +1,70 @@ +package io.tolgee.formats.apple.out + +import org.dom4j.Document +import org.dom4j.DocumentHelper +import org.dom4j.io.OutputFormat +import org.dom4j.io.XMLWriter +import java.io.ByteArrayOutputStream +import java.io.InputStream + +class StringsdictWriter { + private val document: Document = DocumentHelper.createDocument() + private val root = DocumentHelper.createElement("plist") + + init { + document.add(root) + document.addDocType("plist", "-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd") + root.addAttribute("version", "1.0") + root.add(DocumentHelper.createElement("dict")) + } + + fun addEntry( + key: String, + pluralForms: Map, + ) { + val dictElement = root.element("dict") + + val keyElement = DocumentHelper.createElement("key") + keyElement.text = key + dictElement.add(keyElement) + + val dictValueElement = DocumentHelper.createElement("dict") + dictElement.add(dictValueElement) + + // add other necessary elements as needed + val keyLocalizedFormatElement = DocumentHelper.createElement("key") + keyLocalizedFormatElement.text = "NSStringLocalizedFormatKey" + dictValueElement.add(keyLocalizedFormatElement) + + val stringLocalizedFormatElement = DocumentHelper.createElement("string") + stringLocalizedFormatElement.text = "%#\${#@format@}" + dictValueElement.add(stringLocalizedFormatElement) + + val keyFormatElement = DocumentHelper.createElement("key") + keyFormatElement.text = "format" + dictValueElement.add(keyFormatElement) + + val dictFormatElement = DocumentHelper.createElement("dict") + dictValueElement.add(dictFormatElement) + + pluralForms.forEach { (formKey, translation) -> + + val keyQuantityElement = DocumentHelper.createElement("key") + keyQuantityElement.text = formKey + dictFormatElement.add(keyQuantityElement) + + val stringQuantityElement = DocumentHelper.createElement("string") + stringQuantityElement.text = translation + dictFormatElement.add(stringQuantityElement) + } + } + + val result: InputStream + get() { + val format = OutputFormat.createPrettyPrint() + val outputStream = ByteArrayOutputStream() + val writer = XMLWriter(outputStream, format) + writer.write(document) + return outputStream.toByteArray().inputStream() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/constants.kt b/backend/data/src/main/kotlin/io/tolgee/formats/constants.kt new file mode 100644 index 0000000000..ba06ebfc31 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/constants.kt @@ -0,0 +1,3 @@ +package io.tolgee.formats + +const val DEFAULT_PLURAL_ARGUMENT_NAME = "value" diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/escaping/ForceIcuEscaper.kt b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/ForceIcuEscaper.kt new file mode 100644 index 0000000000..cc4d7760c8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/ForceIcuEscaper.kt @@ -0,0 +1,86 @@ +package io.tolgee.formats.escaping + +import io.tolgee.formats.escaping.ForceIcuEscaper.State.* + +/** + * This class forcefully escapes syntax of ICU messages. + * It's ported from Stapan Granat's JS code + */ +class ForceIcuEscaper(private val input: String, private val escapeHash: Boolean = false) { + enum class State { + StateText, + StateEscapedMaybe, + StateEscaping, + } + + private val escapable by lazy { + val base = setOf('{', '}') + if (escapeHash) { + base + setOf('#') + } else { + base + } + } + + private val escapeChar = '\'' + + val escaped by lazy { + var state: State = StateText + val result = StringBuilder() + + for (char in input) { + when (state) { + StateText -> + if (char == escapeChar) { + result.append(char) + state = StateEscapedMaybe + } else if (escapable.contains(char)) { + result.append(escapeChar) + result.append(char) + state = StateEscaping + } else { + result.append(char) + } + + StateEscapedMaybe -> { + if (escapable.contains(char)) { + // escape the EscapeChar + result.append(escapeChar) + // append() another layer of escape on top + result.append(escapeChar) + result.append(char) + state = StateEscaping + } else if (char == escapeChar) { + // two escape chars - escape both + result.append(escapeChar) + result.append(char) + result.append(escapeChar) + state = StateText + } else { + result.append(char) + state = StateText + } + } + + StateEscaping -> { + if (escapable.contains(char)) { + result.append(char) + } else if (char == escapeChar) { + result.append(escapeChar) + result.append(escapeChar) + } else { + result.append(escapeChar) + result.append(char) + state = StateText + } + } + } + } + + if (state == StateEscapedMaybe || state == StateEscaping) { + result.append(escapeChar) + } + + result.toString() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/escaping/IcuUnescper.kt b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/IcuUnescper.kt new file mode 100644 index 0000000000..f25d640c43 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/IcuUnescper.kt @@ -0,0 +1,81 @@ +package io.tolgee.formats.escaping + +/** + * It escapes controlling characters in ICU message, so it's not interpreted when in comes from other formats + */ +class IcuUnescper( + private val input: String, + private val isPlural: Boolean = false, +) { + companion object { + private const val ESCAPE_CHAR = '\'' + } + + enum class State { + StateText, + StateEscapedMaybe, + StateEscaped, + StateEscapeEndMaybe, + } + + private val escapableChars by lazy { + val base = setOf('{', '}', '\'') + if (isPlural) { + base + setOf('#') + } else { + base + } + } + + val unescaped: String + get() { + val result = StringBuilder() + var state = State.StateText + + for (ch in input) { + when (state) { + State.StateText -> { + if (ch == ESCAPE_CHAR) { + state = State.StateEscapedMaybe + } else { + result.append(ch) + } + } + + State.StateEscapedMaybe -> { + if (ch == ESCAPE_CHAR) { + state = State.StateText + } else if (escapableChars.contains(ch)) { + state = State.StateEscaped + } else { + state = State.StateText + result.append(ESCAPE_CHAR) + } + result.append(ch) + } + + State.StateEscaped -> { + if (ch == ESCAPE_CHAR) { + state = State.StateEscapeEndMaybe + } else { + result.append(ch) + } + } + + State.StateEscapeEndMaybe -> { + if (ch == ESCAPE_CHAR) { + state = State.StateEscaped + result.append(ESCAPE_CHAR) + } else { + result.append(ch) + state = State.StateText + } + } + } + } + if (state == State.StateEscapedMaybe) { + result.append(ESCAPE_CHAR) + } + return result.toString() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/escaping/PluralFormIcuEscaper.kt b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/PluralFormIcuEscaper.kt new file mode 100644 index 0000000000..5bc82ce948 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/escaping/PluralFormIcuEscaper.kt @@ -0,0 +1,89 @@ +package io.tolgee.formats.escaping + +/** + * Escapes a plural form, so it doesn't break the full ICU string + */ +class PluralFormIcuEscaper( + private val input: String, + private val escapeHash: Boolean = false, +) { + companion object { + private const val ESCAPE_CHAR = '\'' + } + + private enum class State { + StateText, + StateEscaped, + StateEscapedMaybe, + StateEscapeEndMaybe, + } + + private val escapableChars by lazy { + val base = setOf('{', '}', '\'') + if (escapeHash) { + base + setOf('#') + } else { + base + } + } + + val escaped: String + get() { + var state = State.StateText + val result = StringBuilder() + var lastNeedsEscapeIndex: Int? = null + + input.forEachIndexed { index, char -> + when (state) { + State.StateText -> { + if (char == ESCAPE_CHAR) { + state = State.StateEscapedMaybe + } else if (escapableChars.contains(char)) { + result.append(ESCAPE_CHAR) + state = State.StateEscaped + lastNeedsEscapeIndex = result.length + } + result.append(char) + } + + State.StateEscapedMaybe -> { + if (char == ESCAPE_CHAR) { + state = State.StateText + } else if (escapableChars.contains(char)) { + state = State.StateEscaped + lastNeedsEscapeIndex = result.length + } else { + state = State.StateText + } + result.append(char) + } + + State.StateEscaped -> { + if (char == ESCAPE_CHAR) { + state = State.StateEscapeEndMaybe + } else { + if (escapableChars.contains(char)) { + lastNeedsEscapeIndex = result.length + } + } + result.append(char) + } + + State.StateEscapeEndMaybe -> { + if (char == ESCAPE_CHAR) { + state = State.StateEscaped + result.append(ESCAPE_CHAR) + } else { + result.append(char) + state = State.StateText + } + } + } + } + + if (state == State.StateEscaped) { + result.insert(lastNeedsEscapeIndex!! + 1, ESCAPE_CHAR) + } + return result.toString() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/contsants.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/contsants.kt new file mode 100644 index 0000000000..bf54c151d8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/contsants.kt @@ -0,0 +1,3 @@ +package io.tolgee.formats.flutter + +const val FLUTTER_ARB_FILE_PLACEHOLDERS_CUSTOM_KEY = "_flutterArbPlaceholders" diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/flutterArbModel.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/flutterArbModel.kt new file mode 100644 index 0000000000..c1dce86dff --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/flutterArbModel.kt @@ -0,0 +1,12 @@ +package io.tolgee.formats.flutter + +class FlutterArbModel( + val locale: String?, + val translations: MutableMap = mutableMapOf(), +) + +class FlutterArbTranslationModel( + val value: String?, + val description: String? = null, + val placeholders: Map? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParseException.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParseException.kt new file mode 100644 index 0000000000..5895cd9e7f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParseException.kt @@ -0,0 +1,3 @@ +package io.tolgee.formats.flutter.`in` + +class FlutterArbFileParseException(cause: Exception) : RuntimeException("Cannot parse arb file", cause) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParser.kt new file mode 100644 index 0000000000..98c0f831ee --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileParser.kt @@ -0,0 +1,54 @@ +package io.tolgee.formats.flutter.`in` + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.formats.flutter.FlutterArbModel +import io.tolgee.formats.flutter.FlutterArbTranslationModel + +class FlutterArbFileParser( + private val bytes: ByteArray, + private val objectMapper: ObjectMapper, +) { + fun parse(): FlutterArbModel { + try { + val data = objectMapper.readValue>(bytes) + return parseArbData(data) + } catch (e: Exception) { + throw FlutterArbFileParseException(e) + } + } + + private fun parseArbData(data: Map): FlutterArbModel { + val locale = data["@@locale"] as? String + val translations = + data.entries + .fold(mutableMapOf()) { acc, entry -> + val key = entry.key + if (!key.startsWith("@@") && !key.startsWith("@")) { + val value = entry.value as? String ?: "" + val details = data["@$key"] as? Map<*, *> + val description = details?.get("description") as? String + val placeholders = details?.get("placeholders") + val translationModel = + FlutterArbTranslationModel( + value = value, + description = description, + placeholders = getSafePlaceHoldersMap(placeholders), + ) + acc[key] = translationModel + } + acc + } + + return FlutterArbModel( + locale = locale, + translations = translations, + ) + } + + private fun getSafePlaceHoldersMap(placeholders: Any?) = + (placeholders as? Map<*, *>) + ?.entries?.mapNotNull { (key, value) -> + (key as? String ?: return@mapNotNull null) to value + }?.toMap() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileProcessor.kt new file mode 100644 index 0000000000..e0d7e30694 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/in/FlutterArbFileProcessor.kt @@ -0,0 +1,52 @@ +package io.tolgee.formats.flutter.`in` + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.flutter.FLUTTER_ARB_FILE_PLACEHOLDERS_CUSTOM_KEY +import io.tolgee.formats.optimizePossiblePlural +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class FlutterArbFileProcessor( + override val context: FileProcessorContext, + private val objectMapper: ObjectMapper, +) : + ImportFileProcessor() { + override fun process() { + parsed.translations.forEach { (keyName, item) -> + context.addGenericFormatTranslation(keyName, guessedLanguage, item.value.convertMessage()) + if (item.description != null) { + context.addKeyDescription(keyName, item.description) + } + if (item.placeholders != null) { + context.setCustom(keyName, FLUTTER_ARB_FILE_PLACEHOLDERS_CUSTOM_KEY, item.placeholders) + } + } + } + + private val parsed by lazy { + FlutterArbFileParser(context.file.data, objectMapper).parse() + } + + private fun String?.convertMessage(): String? { + this ?: return null + return optimizePossiblePlural(this) + } + + private val guessedLanguage: String by lazy { + parsed.locale ?: guessLanguageFormFileName() ?: "unknown" + } + + private fun guessLanguageFormFileName(): String? { + val filename = context.file.name + val languagePart = + LANGUAGE_GUESS_REGEX.find(filename)?.let { match -> + match.groups["language"]?.value + } + + return languagePart?.replace("_", "-") + } + + companion object { + val LANGUAGE_GUESS_REGEX = Regex("app_(?[a-zA-Z_]+).arb") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileExporter.kt new file mode 100644 index 0000000000..8dc82a8198 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileExporter.kt @@ -0,0 +1,109 @@ +package io.tolgee.formats.flutter.out + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.DEFAULT_PLURAL_ARGUMENT_NAME +import io.tolgee.formats.flutter.FLUTTER_ARB_FILE_PLACEHOLDERS_CUSTOM_KEY +import io.tolgee.formats.flutter.FlutterArbModel +import io.tolgee.formats.flutter.FlutterArbTranslationModel +import io.tolgee.formats.toIcuPluralString +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class FlutterArbFileExporter( + override val translations: List, + override val exportParams: IExportParams, + private val baseLanguageTag: String, + private val objectMapper: ObjectMapper, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + /** + * Map (Path To file -> Map (Key Name -> Node Wrapper)) + */ + private val fileUnits = mutableMapOf() + + private fun getModels(): Map { + prepare() + + return fileUnits + } + + private fun prepare() { + translations.forEach { translation -> + if (translation.languageTag == baseLanguageTag) { + handleBaseTranslation(translation) + return@forEach + } + handleNonBaseTranslation(translation) + } + } + + private fun handleBaseTranslation(translation: ExportTranslationView) { + getModel(translation).translations[translation.key.name] = + FlutterArbTranslationModel( + value = getConvertedMessage(translation), + description = translation.key.description, + placeholders = getPlaceholders(translation), + ) + } + + private fun handleNonBaseTranslation(translation: ExportTranslationView) { + getModel(translation).translations[translation.key.name] = + FlutterArbTranslationModel( + value = getConvertedMessage(translation), + ) + } + + private fun getPlaceholders(translation: ExportTranslationView): Map? { + val possibleMap = translation.key.custom?.get(FLUTTER_ARB_FILE_PLACEHOLDERS_CUSTOM_KEY) as? Map<*, *> + return possibleMap?.mapNotNull { (key, value) -> + if (key !is String) return@mapNotNull null + key to value + }?.toMap() + } + + private fun getModel(translation: ExportTranslationView): FlutterArbModel { + val path = getFilePath(translation.languageTag) + return fileUnits.computeIfAbsent(path) { + FlutterArbModel( + locale = convertLanguageTag(translation.languageTag), + ) + } + } + + private fun getFilePath(languageTag: String): String { + val tagPart = convertLanguageTag(languageTag) + return "app_$tagPart.$fileExtension" + } + + private fun convertLanguageTag(languageTag: String) = languageTag.replace("-", "_") + + private fun getConvertedMessage(translation: ExportTranslationView): String? { + translation.text ?: return null + val converted = + IcuToFlutterArbMessageConvertor( + message = translation.text ?: "", + forceIsPlural = translation.key.isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + + if (converted.isPlural()) { + return converted.formsResult!!.toIcuPluralString( + addNewLines = false, + argName = converted.argName ?: converted.firstArgName ?: DEFAULT_PLURAL_ARGUMENT_NAME, + ) + } + + return converted.singleResult!! + } + + override val fileExtension: String + get() = "arb" + + override fun produceFiles(): Map { + return getModels().map { (path, model) -> + path to FlutterArbFileWriter(model, objectMapper).produceFile() + }.toMap() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileWriter.kt new file mode 100644 index 0000000000..bf152609e1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFileWriter.kt @@ -0,0 +1,42 @@ +package io.tolgee.formats.flutter.out + +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.formats.flutter.FlutterArbModel +import io.tolgee.formats.flutter.FlutterArbTranslationModel +import java.io.InputStream + +class FlutterArbFileWriter(val model: FlutterArbModel, private val objectMapper: ObjectMapper) { + val result = mutableMapOf() + + fun produceFile(): InputStream { + addLocale() + addTranslations() + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(result).inputStream() + } + + private fun addTranslations() { + model.translations.forEach { + addTranslation(it) + } + } + + private fun addTranslation(it: Map.Entry) { + result[it.key] = it.value.value + addMeta(it) + } + + private fun addMeta(it: Map.Entry) { + if (it.value.description == null && it.value.placeholders == null) { + return + } + val meta = mutableMapOf() + result["@${it.key}"] = meta + it.value.description?.let { meta["description"] = it } + it.value.placeholders?.let { meta["placeholders"] = it } + } + + private fun addLocale() { + val locale = model.locale ?: return + result["@@locale"] = locale + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFromIcuParamConvertor.kt new file mode 100644 index 0000000000..41c17321d8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/FlutterArbFromIcuParamConvertor.kt @@ -0,0 +1,18 @@ +package io.tolgee.formats.flutter.out + +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class FlutterArbFromIcuParamConvertor : FromIcuParamConvertor { + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + return "{${node.name}}" + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String = "{$argName}" +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/IcuToFlutterArbMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/IcuToFlutterArbMessageConvertor.kt new file mode 100644 index 0000000000..8d46e161c4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/flutter/out/IcuToFlutterArbMessageConvertor.kt @@ -0,0 +1,16 @@ +package io.tolgee.formats.flutter.out + +import io.tolgee.formats.MessageConvertorFactory +import io.tolgee.formats.PossiblePluralConversionResult + +class IcuToFlutterArbMessageConvertor( + private val message: String, + private val forceIsPlural: Boolean, + private val isProjectIcuPlaceholdersEnabled: Boolean, +) { + fun convert(): PossiblePluralConversionResult { + return MessageConvertorFactory(message, forceIsPlural, isProjectIcuPlaceholdersEnabled) { + FlutterArbFromIcuParamConvertor() + }.create().convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/generic/IcuToGenericFormatMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/generic/IcuToGenericFormatMessageConvertor.kt new file mode 100644 index 0000000000..0d68de3727 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/generic/IcuToGenericFormatMessageConvertor.kt @@ -0,0 +1,43 @@ +package io.tolgee.formats.generic + +import io.tolgee.formats.BaseIcuMessageConvertor +import io.tolgee.formats.DEFAULT_PLURAL_ARGUMENT_NAME +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.NoOpFromIcuParamConvertor +import io.tolgee.formats.toIcuPluralString + +/** + * Converts ICU message to generic format message + * + * Generic format is a format like JSON or XLIFF, which potentially can be exported with different placeholder + * format + */ +class IcuToGenericFormatMessageConvertor( + private val message: String?, + private val forceIsPlural: Boolean, + private val isProjectIcuPlaceholdersEnabled: Boolean, + private val paramConvertorFactory: () -> FromIcuParamConvertor = { NoOpFromIcuParamConvertor() }, +) { + fun convert(): String? { + message ?: return null + val converted = + BaseIcuMessageConvertor( + message = message, + argumentConvertor = paramConvertorFactory(), + forceIsPlural = forceIsPlural, + keepEscaping = isProjectIcuPlaceholdersEnabled, + ).convert() + + val singleResult = converted.singleResult + if (singleResult != null) { + return singleResult + } + + val formsResult = converted.formsResult ?: return "" + return formsResult + .toIcuPluralString( + addNewLines = false, + argName = converted.argName ?: DEFAULT_PLURAL_ARGUMENT_NAME, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/icuUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/icuUtil.kt new file mode 100644 index 0000000000..25d94d1b51 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/icuUtil.kt @@ -0,0 +1,10 @@ +package io.tolgee.formats + +import io.tolgee.formats.escaping.ForceIcuEscaper + +/** + * When keeping the param as it is, we still need to escape it so it doesn't get interpreted as ICU syntax + */ +fun String.escapeIcu(isInPlural: Boolean): String { + return ForceIcuEscaper(this, isInPlural).escaped +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/json/in/JsonFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/json/in/JsonFileProcessor.kt new file mode 100644 index 0000000000..af51a4c6a0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/json/in/JsonFileProcessor.kt @@ -0,0 +1,80 @@ +package io.tolgee.formats.json.`in` + +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class JsonFileProcessor( + override val context: FileProcessorContext, + private val objectMapper: ObjectMapper, +) : ImportFileProcessor() { + val result = mutableMapOf>() + + override fun process() { + try { + val data = objectMapper.readValue(context.file.data) + data.parse("") + result.entries.forEachIndexed { index, (key, translationTexts) -> + translationTexts.forEach { text -> + context.addGenericFormatTranslation(key, languageNameGuesses[0], text, index) + } + } + } catch (e: JsonParseException) { + throw ImportCannotParseFileException(context.file.name, e.message ?: "") + } catch (e: MismatchedInputException) { + throw ImportCannotParseFileException(context.file.name, e.message ?: "") + } + } + + private fun Any?.parse(keyPrefix: String) { + (this as? List<*>)?.let { + it.parseList(keyPrefix) + return + } + + (this as? Map<*, *>)?.let { + it.parseMap(keyPrefix) + return + } + + addToResult(keyPrefix, this?.toString()) + return + } + + private fun addToResult( + key: String, + value: String?, + ) { + result.compute(key) { _, v -> + val list = v ?: mutableListOf() + list.add(value) + list + } + } + + private fun List<*>.parseList(keyPrefix: String) { + this.forEachIndexed { idx, it -> + it.parse("$keyPrefix[$idx]") + } + } + + private fun Map<*, *>.parseMap(keyPrefix: String) { + this.entries.forEachIndexed { idx, entry -> + val key = entry.key + + if (key !is String) { + context.fileEntity.addKeyIsNotStringIssue(key.toString(), idx) + return@forEachIndexed + } + + val keyPrefixWithDelimiter = + if (keyPrefix.isNotEmpty()) "$keyPrefix${context.params.structureDelimiter}" else "" + entry.value.parse("$keyPrefixWithDelimiter$key") + return@forEachIndexed + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/json/out/JsonFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/json/out/JsonFileExporter.kt new file mode 100644 index 0000000000..dc3c4175eb --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/json/out/JsonFileExporter.kt @@ -0,0 +1,42 @@ +package io.tolgee.formats.json.out + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.nestedStructureModel.StructureModelBuilder +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class JsonFileExporter( + override val translations: List, + override val exportParams: IExportParams, + val convertMessage: (message: String?, isPlural: Boolean) -> String? = { message, _ -> message }, +) : FileExporter { + override val fileExtension: String = ExportFormat.JSON.extension + + val result: LinkedHashMap = LinkedHashMap() + + override fun produceFiles(): Map { + prepare() + return result.asSequence().map { (fileName, modelBuilder) -> + fileName to + jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(modelBuilder.result) + .inputStream() + }.toMap() + } + + private fun prepare() { + translations.forEach { translation -> + val fileContentResult = getFileContentResultBuilder(translation) + fileContentResult.addValue(translation.key.name, convertMessage(translation.text, translation.key.isPlural)) + } + } + + private fun getFileContentResultBuilder(translation: ExportTranslationView): StructureModelBuilder { + val absolutePath = translation.getFilePath() + return result.computeIfAbsent(absolutePath) { + StructureModelBuilder(exportParams.structureDelimiter, exportParams.supportArrays) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/localeUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/localeUtil.kt new file mode 100644 index 0000000000..244bb46e8c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/localeUtil.kt @@ -0,0 +1,26 @@ +package io.tolgee.formats + +import com.ibm.icu.impl.locale.LanguageTag +import com.ibm.icu.util.ULocale +import io.tolgee.component.machineTranslation.LanguageTagConvertor +import io.tolgee.formats.pluralData.PluralData +import io.tolgee.formats.pluralData.PluralLanguage + +fun getULocaleFromTag(tag: String): ULocale { + val suitableTag = + LanguageTagConvertor.findSuitableTag(tag) { newTag -> + LanguageTag.parse(newTag, null).extlangs.size == 0 + } + return ULocale.forLanguageTag(suitableTag ?: "en") +} + +fun getPluralData(languageTag: String): PluralLanguage { + val locale = getULocaleFromTag(languageTag) + return PluralData.DATA[locale.language] ?: throw NoPluralDataException(languageTag) +} + +fun getPluralDataOrNull(locale: ULocale): PluralLanguage? { + return PluralData.DATA[locale.language] +} + +class NoPluralDataException(val languageTag: String) : RuntimeException("No plural data for language tag $languageTag") diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/StructureModelBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/StructureModelBuilder.kt new file mode 100644 index 0000000000..733a1dd0d0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/StructureModelBuilder.kt @@ -0,0 +1,286 @@ +package io.tolgee.formats.nestedStructureModel + +import io.tolgee.formats.path.ArrayPathItem +import io.tolgee.formats.path.ObjectPathItem +import io.tolgee.formats.path.PathItem +import io.tolgee.formats.path.buildPath +import io.tolgee.formats.path.getPathItems + +class StructureModelBuilder( + private var structureDelimiter: Char?, + private var supportJsonArrays: Boolean, +) { + private var model: StructuredModelItem? = null + + val result: Any? + get() { + return model?.toMapOrList() + } + + private fun StructuredModelItem.toMapOrList(): Any? { + return when (this) { + is ObjectStructuredModelItem -> + mutableMapOf().also { theMap -> + this.forEach { (key, value) -> + theMap[key] = value.toMapOrList() + } + } + + is ArrayStructuredModelItem -> + mutableListOf().also { theList -> + this.entries.sortedBy { it.key }.forEach { + theList.add(it.value.toMapOrList()) + } + } + + is ValueStructuredModelItem -> this.value + else -> throw IllegalStateException("Uknown model type") + } + } + + fun addValue( + key: String, + value: String?, + ) { + val path = getPathItems(key, supportJsonArrays, structureDelimiter) + model = model ?: path.first().createNode(null, null) + addToContent(model!!, path, path, value) + } + + private fun addToContent( + parentNode: StructuredModelItem, + pathItems: List, + fullPath: List, + value: String?, + ) { + val pathItemsMutable = pathItems.toMutableList() + + if (pathItems.size == 1) { + putText(parentNode, value, pathItems.first(), fullPath) + return + } + + val pathItem = pathItemsMutable.removeAt(0) + + if (parentNode is ValueStructuredModelItem) { + handleCollisions(pathItem, parentNode, fullPath, value) + } + + when (pathItem) { + is ObjectPathItem -> { + when (parentNode) { + is ObjectStructuredModelItem -> { + val targetNode = + getTargetNodeForObjectItem(parentNode, pathItem, pathItemsMutable) ?: return + addToContent(targetNode, pathItemsMutable, fullPath, value) + } + + is ArrayStructuredModelItem -> { + throw IllegalStateException("Parent node for object item can never be an array") + } + } + } + + is ArrayPathItem -> { + when (parentNode) { + is ArrayStructuredModelItem -> { + val targetNode = + getTargetNodeForArrayItem(parentNode, pathItem, pathItemsMutable) ?: return + addToContent(targetNode, pathItemsMutable, fullPath, value) + } + + is ObjectStructuredModelItem -> { + throw IllegalStateException("Parent node for array item can never be an object") + } + } + } + } + } + + private fun getTargetNodeForObjectItem( + parentNode: ObjectStructuredModelItem, + currentPathItem: ObjectPathItem, + restPathItems: MutableList, + ): StructuredModelItem { + var targetNode = parentNode[currentPathItem.key] + if (targetNode == null) { + targetNode = restPathItems.first().createNode(parentNode, currentPathItem.key) + parentNode[currentPathItem.key] = targetNode + } + return targetNode + } + + private fun getTargetNodeForArrayItem( + parentNode: ArrayStructuredModelItem, + currentPathItem: ArrayPathItem, + restPathItems: MutableList, + ): StructuredModelItem { + var targetNode = parentNode[currentPathItem.index] + if (targetNode == null) { + targetNode = restPathItems.first().createNode(parentNode, currentPathItem.index) + parentNode[currentPathItem.index] = targetNode + return targetNode + } + + return targetNode + } + + private fun putText( + parentNode: StructuredModelItem, + text: String?, + pathItem: PathItem, + fullPath: List, + ) { + handleCollisions(pathItem, parentNode, fullPath, text) + + if (pathItem is ObjectPathItem && parentNode is ObjectStructuredModelItem) { + parentNode.compute(pathItem.key) { _, value -> + throwIfExists(value, fullPath) + ValueStructuredModelItem(text, parentNode, pathItem.key) + } + } + + if (pathItem is ArrayPathItem && parentNode is ArrayStructuredModelItem) { + parentNode.compute(pathItem.index) { _, value -> + throwIfExists(value, fullPath) + ValueStructuredModelItem(text, parentNode, pathItem.index) + } + } + } + + private fun handleCollisions( + pathItem: PathItem, + parentNode: StructuredModelItem, + fullPath: List, + text: String?, + ) { + when (pathItem) { + is ObjectPathItem -> { + when (parentNode) { + is ArrayStructuredModelItem -> { + handleRootIsArrayCollision(parentNode, fullPath, text) + } + + is ValueStructuredModelItem -> { + handleExistingNodeCollisionByJoiningLast2PathSegments(parentNode, fullPath, text) + } + } + } + + is ArrayPathItem -> { + when (parentNode) { + is ObjectStructuredModelItem -> { + handleExistingNodeCollisionByConvertingArrayItemToObjectItem(pathItem, fullPath, text) + } + + is ValueStructuredModelItem -> { + handleExistingNodeCollisionByJoiningLast2PathSegments(parentNode, fullPath, text) + } + } + } + } + } + + private fun throwIfExists( + value: StructuredModelItem?, + fullPath: List, + ) { + if (value != null) { + throw IllegalStateException( + "Cannot add item to node. This is a bug, data should be sorted by key name path. Path: ${ + buildPath( + fullPath, + ) + }", + ) + } + } + + private fun PathItem.createNode( + parentNode: ContainerNode<*>?, + key: Any?, + ): StructuredModelItem { + return when (this) { + is ArrayPathItem -> ArrayStructuredModelItem(parentNode, key) + is ObjectPathItem -> ObjectStructuredModelItem(parentNode, key) + else -> throw IllegalStateException("Root item must be array or object") + } + } + + private fun handleExistingNodeCollisionByJoiningLast2PathSegments( + parentNode: StructuredModelItem, + fullPath: List, + value: String?, + ) { + // we can only use different index + val parent = parentNode.parent + if (parent is ArrayStructuredModelItem) { + handleExistingNodeCollisionByIncreasingIndex(parent, fullPath, value) + return + } + + // for objects, we can basically flatten the last 2 path items + val last2joined = buildPath(fullPath.takeLast(2), structureDelimiter) + val joinedPathItems = fullPath.dropLast(2) + ObjectPathItem(last2joined, last2joined) + addToContent(model!!, joinedPathItems, joinedPathItems, value) + } + + private fun handleExistingNodeCollisionByConvertingArrayItemToObjectItem( + pathItem: ArrayPathItem, + fullPath: List, + text: String?, + ) { + val fullPathMutable = fullPath.toMutableList() + val index = fullPath.indexOf(pathItem) + fullPathMutable[index] = ObjectPathItem("[${pathItem.index}]", "[${pathItem.index}]") + addToContent(model!!, fullPathMutable, fullPath, text) + } + + /** + * since the parent is already an array, we cannot convert it to an object + * so the only option is to use another index + */ + private fun handleExistingNodeCollisionByIncreasingIndex( + node: ArrayStructuredModelItem, + fullPath: List, + value: String?, + ) { + val newIndex = node.keys.maxOrNull()?.plus(1) ?: 0 + val arrayItem = fullPath.getOrNull(fullPath.size - 2) ?: return + (arrayItem as? ArrayPathItem)?.index = newIndex + addToContent(model!!, fullPath, fullPath, value) + } + + private fun handleRootIsArrayCollision( + node: ArrayStructuredModelItem, + fullPath: List, + value: String?, + ) { + replaceArrayWithObjectOnCollision(node) + addToContent(model!!, fullPath, fullPath, value) + } + + private fun replaceArrayWithObjectOnCollision(node: ArrayStructuredModelItem) { + val replacingObjectItem = ObjectStructuredModelItem(node.parent, "[${node.key}]") + node.forEach { (key, value) -> + replacingObjectItem["[$key]"] = value + } + + if (node.parent == null) { + model = replacingObjectItem + return + } + + (node.key as? Int)?.let { + @Suppress("UNCHECKED_CAST") + (node.parent as MutableMap).put(it, replacingObjectItem) + return + } + + (node.key as? String)?.let { + @Suppress("UNCHECKED_CAST") + (node.parent as MutableMap).put(it, replacingObjectItem) + return + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/nestedStructureModel.kt b/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/nestedStructureModel.kt new file mode 100644 index 0000000000..c2eaf89074 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/nestedStructureModel/nestedStructureModel.kt @@ -0,0 +1,28 @@ +package io.tolgee.formats.nestedStructureModel + +interface StructuredModelItem { + val parent: ContainerNode<*>? + val key: Any? +} + +interface ContainerNode : MutableMap, StructuredModelItem + +class ValueStructuredModelItem( + val value: String?, + override val parent: ContainerNode<*>?, + override val key: Any?, +) : + StructuredModelItem + +class ObjectStructuredModelItem( + override val parent: ContainerNode<*>?, + override val key: Any?, +) : + LinkedHashMap(), ContainerNode + +class ArrayStructuredModelItem( + override val parent: ContainerNode<*>?, + override val key: Any?, +) : LinkedHashMap(), + StructuredModelItem, + ContainerNode diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/paramConversionUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/paramConversionUtil.kt new file mode 100644 index 0000000000..1e39402f49 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/paramConversionUtil.kt @@ -0,0 +1,48 @@ +package io.tolgee.formats + +import io.tolgee.formats.escaping.ForceIcuEscaper +import io.tolgee.formats.po.`in`.ParsedCLikeParam + +/** + * Handles the float conversion to ICU format + * Return null if it cannot be converted reliably + */ +fun convertFloatToIcu( + parsed: ParsedCLikeParam, + name: String, +): String? { + val precision = parsed.precision?.toLong() ?: 6 + val tooPrecise = precision > 50 + val usesUnsupportedFeature = usesUnsupportedFeature(parsed) + if (tooPrecise || usesUnsupportedFeature) { + return null + } + val precisionString = ".${(1..precision).joinToString("") { "0" }}" + return "{$name, number, $precisionString}" +} + +fun usesUnsupportedFeature(parsed: ParsedCLikeParam) = + parsed.width != null || parsed.flags != null || parsed.length != null + +fun convertMessage( + message: String, + isInPlural: Boolean, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + convertorFactory: () -> ToIcuParamConvertor, +): String { + if (!isProjectIcuEnabled && !isInPlural) return message + if (!convertPlaceholders) return message.escapeIcu(true) + + val convertor = convertorFactory() + return message.replaceMatchedAndUnmatched( + string = message, + regex = convertor.regex, + matchedCallback = { + convertor.convert(it, isInPlural) + }, + unmatchedCallback = { + ForceIcuEscaper(it, isInPlural).escaped + }, + ) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/path/PathParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/path/PathParser.kt new file mode 100644 index 0000000000..9da6d46355 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/path/PathParser.kt @@ -0,0 +1,182 @@ +package io.tolgee.formats.path + +import io.tolgee.util.nullIfEmpty + +class PathParser( + private val path: String, + private val arraySupport: Boolean, + private val structureDelimiter: Char? = '.', +) { + private val items = mutableListOf() + private val buffer = StringBuilderWithIndexes(path) + private var state = State.INIT_ITEM + private var itemValue = "" + private var bracketCount = 0 + + fun parse(): MutableList { + path.forEachIndexed { index, ch -> + handleChar(ch, index) + } + + when (state) { + State.POSSIBLE_ARRAY_END -> { + handleArrayEnd(null, path.length) + } + + State.INIT_ITEM -> { + items.add(ObjectPathItem("", "")) + } + + State.NORMAL -> { + itemValue = buffer.toString() + items.add(ObjectPathItem(itemValue, buffer.originalString())) + } + + else -> {} + } + + return items + } + + private fun handleChar( + ch: Char, + index: Int, + ) { + when (state) { + State.NORMAL, State.INIT_ITEM -> { + when (ch) { + structureDelimiter -> { + itemValue = buffer.toString() + items.add(ObjectPathItem(itemValue, buffer.originalString())) + buffer.clear(index) + state = State.INIT_ITEM + } + + '[' -> { + if (arraySupport) { + bracketCount = 1 + state = State.IN_BRACKETS + } + buffer.append('[', index) + } + + '\\' -> state = State.IN_ESCAPE + else -> { + state = State.NORMAL + buffer.append(ch, index) + } + } + } + + State.IN_ESCAPE -> { + buffer.append(ch, index) + state = State.NORMAL + } + + State.IN_BRACKETS -> + when (ch) { + ']' -> { + bracketCount-- + buffer.append(ch, index) + if (bracketCount == 0) { + state = State.POSSIBLE_ARRAY_END + } + } + + '[' -> { + bracketCount++ + buffer.append(ch, index) + } + + else -> buffer.append(ch, index) + } + + State.POSSIBLE_ARRAY_END -> { + when (ch) { + structureDelimiter -> { + handleArrayEnd(ch, index) + } + + '[' -> { + handleArrayEnd(ch, index) + } + + else -> { + state = State.NORMAL + handleChar(ch, index) + } + } + } + } + } + + private fun handleArrayEnd( + ch: Char? = null, + index: Int, + ) { + val bufferString = buffer.toString() + val groups = indexParseRegex.matchEntire(bufferString)?.groups + val indexGroup = groups?.get("index")?.value + val maybeIndex = indexGroup?.toIntOrNull() + val preArray = groups?.get("preArray")?.value + if (maybeIndex != null) { + val originalString = buffer.originalString() + preArray?.nullIfEmpty?.let { + val preArrayOriginal = originalString.substring(0, originalString.length - indexGroup.length - 2) + items.add(ObjectPathItem(it, preArrayOriginal)) + } + items.add(ArrayPathItem(maybeIndex, maybeIndex.toString())) + buffer.clear(index) + } + + if (ch == null) { + return + } + + state = State.NORMAL + if (ch != structureDelimiter || maybeIndex == null) { + handleChar(ch, index) + } + } +} + +private class StringBuilderWithIndexes( + val path: String, +) { + var start: Int = 0 + var end: Int = 0 + + private val sb = StringBuilder() + + fun clear(index: Int) { + this.start = index + 1 + this.end = index + 1 + sb.clear() + } + + fun append( + ch: Char, + index: Int, + ) { + sb.append(ch) + end = index + 1 + } + + override fun toString() = sb.toString() + + fun originalString(): String { + return path.substring(start, end) + } +} + +val indexParseRegex by lazy { + "^(?.*)\\[(?[0-9]+)\\]$".toRegex() +} + +enum class State { + INIT_ITEM, + NORMAL, + IN_ESCAPE, + IN_BRACKETS, + POSSIBLE_ARRAY_END, +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/path/pathItemUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/path/pathItemUtil.kt new file mode 100644 index 0000000000..0e7b45b695 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/path/pathItemUtil.kt @@ -0,0 +1,39 @@ +package io.tolgee.formats.path + +interface PathItem { + val originalPathString: String +} + +class ArrayPathItem(var index: Int, override val originalPathString: String) : PathItem + +class ObjectPathItem(var key: String, override val originalPathString: String) : PathItem + +fun getPathItems( + path: String, + arraySupport: Boolean, + structureDelimiter: Char? = '.', +): MutableList { + return PathParser(path, arraySupport, structureDelimiter).parse() +} + +fun buildPath( + items: List, + structureDelimiter: Char? = '.', +): String { + val path = StringBuilder() + for (i in items.indices) { + when (val item = items[i]) { + is ObjectPathItem -> { + path.append(item.originalPathString) + } + + is ArrayPathItem -> path.append("[${item.index}]") + } + + // Add dot separator if the next item is not ArrayPathItem + if (i < items.size - 1 && items[i + 1] !is ArrayPathItem) { + path.append(structureDelimiter) + } + } + return path.toString() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralData.kt b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralData.kt similarity index 99% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralData.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralData.kt index 1ca8d345a3..52602805db 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralData.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.messageFormat.data +package io.tolgee.formats.pluralData class PluralData { companion object { diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralExample.kt b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralExample.kt similarity index 50% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralExample.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralExample.kt index 7274e53447..d8b4aff323 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralExample.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralExample.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.messageFormat.data +package io.tolgee.formats.pluralData data class PluralExample( val plural: Int, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralLanguage.kt b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralLanguage.kt similarity index 73% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralLanguage.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralLanguage.kt index 942dcc2864..1fbaeffb42 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/data/PluralLanguage.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/pluralData/PluralLanguage.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.messageFormat.data +package io.tolgee.formats.pluralData data class PluralLanguage( val tag: String, diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormExamplesUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormExamplesUtil.kt new file mode 100644 index 0000000000..d41ea3df4a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormExamplesUtil.kt @@ -0,0 +1,65 @@ +package io.tolgee.formats + +import com.ibm.icu.text.PluralRules + +private val POSSIBLE_MANY = arrayOf(6, 7, 8, 11, 20, 21, 1000000, 0.5, 0.1, 0.0) +private val POSSIBLE_FEW = arrayOf(0, 2, 3, 4, 6) +private val POSSIBLE_OTHER = arrayOf(10, 11, 20, 100, 0.0, 0, 0.1, 2, 3, 4) + +private val KEYWORD_ZERO = "zero" +private val KEYWORD_ONE = "one" +private val KEYWORD_TWO = "two" +private val KEYWORD_FEW = "few" +private val KEYWORD_MANY = "many" +private val KEYWORD_OTHER = "other" + +private val ALL_KEYWORDS = + arrayOf( + KEYWORD_ZERO, + KEYWORD_ONE, + KEYWORD_TWO, + KEYWORD_FEW, + KEYWORD_MANY, + KEYWORD_OTHER, + ) + +private fun findPluralFormExample( + variant: String, + rules: PluralRules, + list: Array, +): Number { + return list.find { + val double = it.toDouble() + rules.select(double) == variant + } ?: 10 +} + +fun getPluralFormExamples(languageTag: String): Map { + val locale = getULocaleFromTag(languageTag) + val rules = PluralRules.forLocale(locale) + return getPluralFormExamples(rules) +} + +fun getPluralFormExamples(rules: PluralRules): Map { + return rules.orderedKeywords.associateWith { + getVariantExample(rules, it) + } +} + +val PluralRules.orderedKeywords + get() = keywords.toSortedSet { a, b -> ALL_KEYWORDS.indexOf(a) - ALL_KEYWORDS.indexOf(b) } + +fun getVariantExample( + rules: PluralRules, + variant: String, +): Number { + return when (variant) { + "zero" -> findPluralFormExample("zero", rules, arrayOf(0)) + "one" -> findPluralFormExample("one", rules, arrayOf(1)) + "two" -> findPluralFormExample("two", rules, arrayOf(2)) + "few" -> findPluralFormExample("few", rules, POSSIBLE_FEW) + "many" -> findPluralFormExample("many", rules, POSSIBLE_MANY) + "other" -> findPluralFormExample("other", rules, POSSIBLE_OTHER) + else -> variant.substring(1).toDoubleOrNull() ?: 10 + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormsUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormsUtil.kt new file mode 100644 index 0000000000..c534c8f7b3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/pluralFormsUtil.kt @@ -0,0 +1,357 @@ +package io.tolgee.formats + +import com.ibm.icu.text.PluralRules +import io.tolgee.formats.escaping.ForceIcuEscaper +import io.tolgee.formats.escaping.IcuUnescper +import io.tolgee.formats.escaping.PluralFormIcuEscaper +import io.tolgee.util.nullIfEmpty + +fun getPluralFormsForLocale(languageTag: String): MutableSet { + val uLocale = getULocaleFromTag(languageTag) + val pluralRules = PluralRules.forLocale(uLocale) + return pluralRules.keywords.sortedBy { + formKeywords.indexOf(it) + }.toMutableSet() +} + +fun populateForms( + languageTag: String, + forms: Map, +): Map { + val otherForm = forms["other"] ?: "" + val allForms = getPluralFormsForLocale(languageTag) + return allForms.associateWith { (forms[it] ?: otherForm) } +} + +fun orderPluralForms(pluralForms: Map): Map { + return pluralForms.entries.sortedBy { + val formIndex = formKeywords.indexOf(it.key) + if (formIndex == -1) { + "A_$it" + } else { + formIndex.toString() + } + }.associate { it.key to it.value } +} + +val formKeywords = listOf("zero", "one", "two", "few", "many", "other") + +/** + * It takes the plurals and optimizes them by removing the unnecessary forms + * It also sorts it, so the strings can be compared and should be the same for the same forms + */ +fun optimizePossiblePlural(string: String): String { + val forms = getPluralForms(string) ?: return string + val optimizedForms = optimizePluralForms(forms.forms) + return FormsToIcuPluralConvertor( + optimizedForms, + addNewLines = true, + argName = forms.argName, + ).convert() +} + +/** + * Returns all plural forms from the given ICU string + * Returns null if the string is not a plural + */ +fun getPluralForms(string: String?): PluralForms? { + string ?: return null + val converted = convertIcuStringNoOp(string) + return getPluralFormsFromConversionResult(converted) +} + +/** + * Returns all plural forms from the given ICU string + * Returns null if the string is not a plural + */ +fun getPluralFormsReplacingReplaceParam( + string: String, + replacement: String, +): PluralForms? { + val noOpConvertor = NoOpFromIcuParamConvertor() + val convertor = + object : FromIcuParamConvertor { + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + return noOpConvertor.convert(node, isInPlural) + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return replacement + } + } + val converted = + BaseIcuMessageConvertor( + string, + convertor, + keepEscaping = true, + ).convert() + return getPluralFormsFromConversionResult(converted) +} + +private fun getPluralFormsFromConversionResult(converted: PossiblePluralConversionResult): PluralForms? { + return PluralForms( + converted.formsResult ?: return null, + converted.argName ?: throw IllegalStateException("Plural argument name not found"), + ) +} + +data class PluralForms( + val forms: Map, + val argName: String, +) { + val icuString: String + get() = forms.toIcuPluralString(optimize = false, argName = argName) +} + +fun optimizePluralForms(forms: Map): Map { + val otherForm = forms[PluralRules.KEYWORD_OTHER] ?: return forms + val filtered = forms.filter { it.key == PluralRules.KEYWORD_OTHER || it.value != otherForm } + return orderPluralForms(filtered) +} + +/** + * It takes the plurals and optimizes them by removing the unnecessary forms + * It also sorts it, so the strings can be compared and should be the same for the same forms + */ +infix fun String?.isSamePossiblePlural(other: String?): Boolean { + if (this == other) { + return true + } + if (this == null || other == null) { + return false + } + return optimizePossiblePlural(this) == optimizePossiblePlural(other) +} + +fun String?.convertToIcuPlural(newPluralArgName: String?): String? { + if (this == null) { + return null + } + return mapOf(1 to this).convertToIcuPlurals(newPluralArgName).convertedStrings[1] +} + +/** + * Converts map of strings to ICU plurals + * If value is null, result is also null + */ +fun Map.convertToIcuPlurals(newPluralArgName: String?): ConvertToIcuPluralResult { + val possibleArgNames = mutableListOf() + val invalid = mutableSetOf() + val formResults = + this.map { entry -> + entry.value ?: return@map entry.key to null + entry.key to ( + try { + val value = entry.value ?: return@map entry.key to null + val converted = + convertIcuStringNoOp(value) + + (converted.argName ?: converted.firstArgName)?.let { + possibleArgNames.add(it) + } + + converted.formsResult + } catch (e: Exception) { + null + } ?: let { + invalid.add(entry.key) + mapOf("other" to entry.value!!) + } + ) + }.toMap() + + val argName = getArgName(possibleArgNames, newPluralArgName) + val convertedStrings = + formResults.map { (key, forms) -> + val preparedForms = forms?.preparePluralForms(escapeHash = invalid.contains(key)) + key to preparedForms.preparedFormsToIcuPlural(argName) + }.toMap() + return ConvertToIcuPluralResult(convertedStrings, argName) +} + +private fun convertIcuStringNoOp(string: String) = + BaseIcuMessageConvertor( + string, + NoOpFromIcuParamConvertor(), + keepEscaping = true, + ).convert() + +data class ConvertToIcuPluralResult( + val convertedStrings: Map, + val argName: String, +) + +/** + * Normalizes list of plurals. Uses provided argument name if any, otherwise it tries to find the most common one + */ +fun normalizePlurals( + strings: Map, + pluralArgName: String? = null, +): Map { + val invalidStrings = mutableListOf() + val formResults = + strings.map { + val text = it.value?.nullIfEmpty ?: return@map it.key to null + + val forms = + try { + getPluralForms(text) + } catch (e: Exception) { + null + } + + if (forms == null) { + invalidStrings.add(text) + } + + it.key to forms + }.toMap() + + if (invalidStrings.isNotEmpty()) { + throw StringIsNotPluralException(invalidStrings) + } + + return pluralFormsToSameArgName(formResults, pluralArgName).convertedStrings +} + +/** + * This method is useful when Support for ICU is disabled on project level and we + * store such plural strings with escaped forms. + * + * Returns null if the string is not a plural + */ +fun String.forceEscapePluralForms(): String? { + val forms = getPluralForms(this) + val escapedForms = forms?.forms?.mapValues { ForceIcuEscaper(it.value, escapeHash = true).escaped } + return escapedForms?.toIcuPluralString(optimize = false, argName = forms.argName) +} + +/** + * This method is useful when Support for ICU is disabled on project level and we + * store such plural strings with escaped forms. + * + * Returns null if the string is not a plural + */ +fun String.unescapePluralForms(): String? { + val forms = getPluralForms(this) + val unescaped = forms?.forms?.mapValues { IcuUnescper(it.value, isPlural = true).unescaped } + return unescaped?.toIcuPluralString(optimize = false, argName = forms.argName) +} + +/** + * Convert plurals to the same argument name + */ +private fun pluralFormsToSameArgName( + formResults: Map, + pluralArgName: String?, +): ConvertToIcuPluralResult { + val argName = getArgName(formResults.values, pluralArgName) + val convertedStrings = + formResults.map { (key, forms) -> + val preparedForms = forms?.forms?.preparePluralForms() + key to preparedForms.preparedFormsToIcuPlural(argName) + }.toMap() + return ConvertToIcuPluralResult(convertedStrings, argName) +} + +private fun Map.preparePluralForms(escapeHash: Boolean = false): Map { + return this.mapValues { + it.value.preparePluralForm(escapeHash) + } +} + +private fun Map?.preparedFormsToIcuPlural(argName: String): String? { + return this?.let { + FormsToIcuPluralConvertor( + it, + addNewLines = true, + argName = argName, + ).convert() + } +} + +fun Map.toIcuPluralString( + optimize: Boolean = true, + addNewLines: Boolean = true, + argName: String, +): String { + return FormsToIcuPluralConvertor( + this, + optimize = optimize, + addNewLines = addNewLines, + argName = argName, + ).convert() +} + +class StringIsNotPluralException(val invalidStrings: List) : RuntimeException("String is not a plural") + +private fun String.preparePluralForm(escapeHash: Boolean = false): String { + return try { + val result = StringBuilder() + MessagePatternUtil.buildMessageNode(this).contents.forEach { + if (it !is MessagePatternUtil.TextNode) { + result.append(it.patternString) + return@forEach + } + result.append(PluralFormIcuEscaper(it.patternString, escapeHash = escapeHash).escaped) + } + result.toString() + } catch (e: Exception) { + PluralFormIcuEscaper(this, escapeHash).escaped + } +} + +fun String.isPluralString(): Boolean { + return try { + getPluralForms(this)?.forms != null + } catch (e: Exception) { + false + } +} + +/** + * Returns new map with plural forms if any of the values is plural + * Returns null if none of the values is plural + */ +fun Map.convertToPluralIfAnyIsPlural(): ConvertToIcuPluralResult? { + val shouldBePlural = this.any { it.value?.isPluralString() == true } + if (!shouldBePlural) { + return null + } + + return this.convertToIcuPlurals(null) +} + +/** + * Returns provided argument name witn max count + */ +private fun getArgName( + forms: Collection, + pluralArgName: String?, +): String { + return getArgName(forms.mapNotNull { it?.argName }, pluralArgName) +} + +/** + * Returns provided argument name witn max count + */ +private fun getArgName( + possibleArgNames: List, + pluralArgName: String?, +): String { + if (!pluralArgName.isNullOrBlank()) { + return pluralArgName + } + + val possibleArgNameSet = possibleArgNames.toSet() + return possibleArgNameSet.map { argName -> + argName to + possibleArgNames.count { it == argName } + }.maxByOrNull { it.second }?.first ?: DEFAULT_PLURAL_ARGUMENT_NAME +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/PoSupportedMessageFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/PoSupportedMessageFormat.kt new file mode 100644 index 0000000000..7c39869879 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/PoSupportedMessageFormat.kt @@ -0,0 +1,66 @@ +package io.tolgee.formats.po + +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.po.`in`.paramConvertors.CToIcuParamConvertor +import io.tolgee.formats.po.`in`.paramConvertors.PhpToIcuParamConvertor +import io.tolgee.formats.po.out.ToPoMessageConvertor +import io.tolgee.formats.po.out.c.ToCPoMessageConvertor +import io.tolgee.formats.po.out.php.ToPhpPoMessageConvertor + +enum class PoSupportedMessageFormat( + val poFlag: String, + val paramRegex: Regex, + val importMessageConvertorType: ImportMessageConvertorType, + val exportMessageConverter: ( + message: String, + languageTag: String, + forceIsPlural: Boolean, + projectIcuPlaceholdersSupport: Boolean, + ) -> ToPoMessageConvertor, +) { + PHP( + poFlag = "php-format", + importMessageConvertorType = ImportMessageConvertorType.PO_PHP, + paramRegex = PhpToIcuParamConvertor.PHP_PARAM_REGEX, + exportMessageConverter = { message, languageTag, forceIsPlural, projectIcuPlaceholdersSupport -> + ToPhpPoMessageConvertor( + message, + languageTag, + forceIsPlural, + projectIcuPlaceholdersSupport, + ) + }, + ), + C( + poFlag = "c-format", + importMessageConvertorType = ImportMessageConvertorType.PO_C, + paramRegex = CToIcuParamConvertor.C_PARAM_REGEX, + exportMessageConverter = { message, languageTag, forceIsPlural, projectIcuPlaceholdersSupport -> + ToCPoMessageConvertor( + message, + languageTag, + forceIsPlural, + projectIcuPlaceholdersSupport, + ) + }, + ), +// PYTHON( +// poFlag = "python-format", +// importMessageConvertorType = ImportMessageConvertorType.PO_PYTHON, +// paramRegex = PythonToIcuParamConvertor.PYTHON_PARAM_REGEX, +// exportMessageConverter = { message, languageTag, forceIsPlural, projectIcuPlaceholdersSupport -> +// ToPythonPoMessageConvertor( +// message, +// languageTag, +// forceIsPlural, +// projectIcuPlaceholdersSupport, +// ) +// }, +// ), + + ; + + companion object { + fun findByFlag(poFlag: String) = entries.find { it.poFlag == poFlag } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt new file mode 100644 index 0000000000..7318a6a684 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt @@ -0,0 +1,3 @@ +package io.tolgee.formats.po + +const val PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY = "_poFileMsgIdPlural" diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/CLikeParameterParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/CLikeParameterParser.kt new file mode 100644 index 0000000000..8fffcbcc29 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/CLikeParameterParser.kt @@ -0,0 +1,30 @@ +package io.tolgee.formats.po.`in` + +class CLikeParameterParser { + fun parse(match: MatchResult): ParsedCLikeParam? { + val specifierGroup = match.groups["specifier"] ?: return null + val specifier = specifierGroup.value + + return ParsedCLikeParam( + argNum = match.groups.getGroupOrNull("argnum")?.value, + argName = match.groups.getGroupOrNull("argname")?.value, + width = match.groups.getGroupOrNull("width")?.value?.toIntOrNull(), + precision = match.groups.getGroupOrNull("precision")?.value?.toInt(), + length = match.groups.getGroupOrNull("length")?.value, + specifier = specifier, + flags = match.groups.getGroupOrNull("flags")?.value, + fullMatch = match.value, + ) + } + + private fun MatchGroupCollection.getGroupOrNull(name: String): MatchGroup? { + try { + return this[name] + } catch (e: IllegalArgumentException) { + if (e.message?.contains("No group with name") != true) { + throw e + } + return null + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/FormatDetector.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/FormatDetector.kt similarity index 57% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/FormatDetector.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/FormatDetector.kt index 982be7a656..a29a01c383 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/FormatDetector.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/FormatDetector.kt @@ -1,18 +1,16 @@ -package io.tolgee.service.dataImport.processors.messageFormat +package io.tolgee.formats.po.`in` + +import io.tolgee.formats.po.PoSupportedMessageFormat class FormatDetector(private val messages: List) { /** * Tries to detect message format by on all messages in file */ - operator fun invoke(): SupportedFormat { + operator fun invoke(): PoSupportedMessageFormat { val regulars = - mapOf( - SupportedFormat.C to ToICUConverter.C_PARAM_REGEX, - SupportedFormat.PHP to ToICUConverter.PHP_PARAM_REGEX, - SupportedFormat.PYTHON to ToICUConverter.PYTHON_PARAM_REGEX, - ) + PoSupportedMessageFormat.entries.associateWith { it.paramRegex } - val hitsMap = mutableMapOf() + val hitsMap = mutableMapOf() regulars.forEach { regularEntry -> val format = regularEntry.key val regex = regularEntry.value @@ -21,7 +19,7 @@ class FormatDetector(private val messages: List) { } } - var result = SupportedFormat.PHP + var result = PoSupportedMessageFormat.PHP var maxValue = 0 hitsMap.forEach { diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/ParsedCLikeParam.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/ParsedCLikeParam.kt new file mode 100644 index 0000000000..1a0ae50aa2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/ParsedCLikeParam.kt @@ -0,0 +1,12 @@ +package io.tolgee.formats.po.`in` + +data class ParsedCLikeParam( + val argNum: String?, + val argName: String?, + val specifier: String, + val width: Int?, + val precision: Int?, + val flags: String?, + val fullMatch: String, + val length: String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt similarity index 53% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoFileProcessor.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt index 77ba674155..d21c7e683c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoFileProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt @@ -1,16 +1,17 @@ -package io.tolgee.service.dataImport.processors.po +package io.tolgee.formats.po.`in` -import com.ibm.icu.util.ULocale import io.tolgee.exceptions.ImportCannotParseFileException import io.tolgee.exceptions.PoParserException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.StringWrapper +import io.tolgee.formats.po.PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.formats.po.`in`.data.PoParsedTranslation +import io.tolgee.formats.po.`in`.data.PoParserResult import io.tolgee.model.dataImport.ImportLanguage import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.ImportFileProcessor -import io.tolgee.service.dataImport.processors.messageFormat.FormatDetector -import io.tolgee.service.dataImport.processors.messageFormat.SupportedFormat -import io.tolgee.service.dataImport.processors.messageFormat.ToICUConverter -import io.tolgee.service.dataImport.processors.po.data.PoParsedTranslation -import io.tolgee.service.dataImport.processors.po.data.PoParserResult +import io.tolgee.util.nullIfEmpty class PoFileProcessor( override val context: FileProcessorContext, @@ -33,10 +34,15 @@ class PoFileProcessor( return@forEachIndexed } if (poTranslation.msgid.isNotBlank()) { - val icuMessage = - getToIcuConverter(poTranslation) - .convert(poTranslation.msgstr.toString()) - context.addTranslation(keyName, languageId, icuMessage, idx) + val converted = getConvertedMessage(poTranslation, poTranslation.msgstr.toString()) + context.addTranslation( + keyName = keyName, + languageName = languageId, + value = converted.first, + idx = idx, + rawData = StringWrapper(poTranslation.msgstr.toString()), + convertedBy = converted.second, + ) poTranslation.meta.references.forEach { reference -> val split = reference.split(":") @@ -46,14 +52,11 @@ class PoFileProcessor( context.addKeyCodeReference(keyName, it, line?.toLong()) } } + + // we use only extracted comments. Translator comments should stay in tolgee and are useless for export if (poTranslation.meta.extractedComments.isNotEmpty()) { val extractedComments = poTranslation.meta.extractedComments.joinToString(" ") - context.addKeyComment(keyName, extractedComments) - } - - if (poTranslation.meta.translatorComments.isNotEmpty()) { - val translatorComments = poTranslation.meta.translatorComments.joinToString(" ") - context.addKeyComment(keyName, translatorComments) + context.addKeyDescription(keyName, extractedComments) } } } @@ -68,20 +71,34 @@ class PoFileProcessor( ) { val plurals = poTranslation.msgstrPlurals?.map { it.key to it.value.toString() }?.toMap() plurals?.let { - val icuMessage = - ToICUConverter(ULocale(languageId), getMessageFormat(poTranslation), context) - .convertPoPlural(plurals) - context.addTranslation(poTranslation.msgidPlural.toString(), languageId, icuMessage, idx) + val (message, convertedBy) = getConvertedMessage(poTranslation, plurals) + val keyName = poTranslation.msgid.toString() + poTranslation.msgidPlural.toString().nullIfEmpty?.let { + context.setCustom(keyName, PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY, it) + } + context.addTranslation(keyName, languageId, message, idx, rawData = plurals, convertedBy = convertedBy) } } - private fun getToIcuConverter(poTranslation: PoParsedTranslation): ToICUConverter { - return ToICUConverter(ULocale(languageId), getMessageFormat(poTranslation), context) + private fun getConvertedMessage( + poTranslation: PoParsedTranslation, + stringOrPluralForms: Any?, + ): Pair { + val messageFormat = getMessageFormat(poTranslation) + val convertor = messageFormat.importMessageConvertorType.importMessageConvertor ?: return (null to null) + val icuMessage = + convertor.convert( + rawData = stringOrPluralForms, + languageTag = languageId, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled, + ).message + return icuMessage to messageFormat.importMessageConvertorType } - private fun getMessageFormat(poParsedTranslation: PoParsedTranslation): SupportedFormat { + private fun getMessageFormat(poParsedTranslation: PoParsedTranslation): PoSupportedMessageFormat { poParsedTranslation.meta.flags.forEach { - SupportedFormat.findByFlag(it) + PoSupportedMessageFormat.findByFlag(it) ?.let { found -> return found } } return detectedFormat diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt similarity index 96% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoParser.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt index 398093e82c..bcb56f4a19 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/PoParser.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt @@ -1,12 +1,12 @@ -package io.tolgee.service.dataImport.processors.po +package io.tolgee.formats.po.`in` import io.tolgee.exceptions.PoParserException +import io.tolgee.formats.po.`in`.data.PoParsedTranslation +import io.tolgee.formats.po.`in`.data.PoParserMeta +import io.tolgee.formats.po.`in`.data.PoParserResult import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.po.data.PoParsedTranslation -import io.tolgee.service.dataImport.processors.po.data.PoParserMeta -import io.tolgee.service.dataImport.processors.po.data.PoParserResult import java.util.* class PoParser( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParsedTranslation.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt similarity index 91% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParsedTranslation.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt index 1e23a9ad4a..cf4bc44c64 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParsedTranslation.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.po.data +package io.tolgee.formats.po.`in`.data class PoParsedTranslation { var msgid: StringBuilder = StringBuilder() diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserMeta.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserMeta.kt similarity index 76% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserMeta.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserMeta.kt index b2a9b54274..dbcd39b0d9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserMeta.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserMeta.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.po.data +package io.tolgee.formats.po.`in`.data class PoParserMeta { var projectIdVersion: String? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserResult.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserResult.kt similarity index 64% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserResult.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserResult.kt index eb4591eef3..1f3b20169b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoParserResult.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParserResult.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.po.data +package io.tolgee.formats.po.`in`.data data class PoParserResult( val meta: PoParserMeta, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoTranslationMeta.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoTranslationMeta.kt similarity index 90% rename from backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoTranslationMeta.kt rename to backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoTranslationMeta.kt index dd9b33aeca..7d4c7c3c63 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/po/data/PoTranslationMeta.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoTranslationMeta.kt @@ -1,4 +1,4 @@ -package io.tolgee.service.dataImport.processors.po.data +package io.tolgee.formats.po.`in`.data class PoTranslationMeta { var translatorComments: MutableList = mutableListOf() diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/BasePoToIcuMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/BasePoToIcuMessageConvertor.kt new file mode 100644 index 0000000000..d0340be652 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/BasePoToIcuMessageConvertor.kt @@ -0,0 +1,70 @@ +package io.tolgee.formats.po.`in`.messageConvertors + +import com.ibm.icu.text.PluralRules +import com.ibm.icu.util.ULocale +import io.tolgee.formats.FormsToIcuPluralConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertMessage +import io.tolgee.formats.getULocaleFromTag +import io.tolgee.formats.pluralData.PluralData + +class BasePoToIcuMessageConvertor(private val paramConvertorFactory: () -> ToIcuParamConvertor) { + fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + val stringValue = rawData as? String ?: (rawData as? Map<*, *>)?.get("_stringValue") as? String + + if (stringValue is String) { + val converted = convert(stringValue, false, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, false) + } + + if (rawData is Map<*, *>) { + val converted = convertPoPlural(rawData, languageTag, convertPlaceholders, isProjectIcuEnabled) + return MessageConvertorResult(converted, true) + } + + return MessageConvertorResult(null, false) + } + + private fun convertPoPlural( + possiblePluralForms: Map<*, *>, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + val forms = + possiblePluralForms.entries.associate { (formNumPossibleString, value) -> + val formNumber = (formNumPossibleString as? Int) ?: (formNumPossibleString as? String)?.toIntOrNull() + if (formNumber !is Int || value !is String) { + throw IllegalArgumentException("Plural forms must be a map of Int to String") + } + val locale = getULocaleFromTag(languageTag) + val example = findSuitableExample(formNumber, locale) + val keyword = PluralRules.forLocale(locale).select(example.toDouble()) + keyword to (convert(value, true, convertPlaceholders, isProjectIcuEnabled)) + } + return FormsToIcuPluralConvertor(forms, addNewLines = true, argName = "0").convert() + } + + private fun findSuitableExample( + key: Int, + locale: ULocale, + ): Int { + val examples = PluralData.DATA[locale.language]?.examples ?: PluralData.DATA["en"]!!.examples + return examples.find { it.plural == key }?.sample ?: examples[0].sample + } + + private fun convert( + message: String, + isInPlural: Boolean = false, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): String { + return convertMessage(message, isInPlural, convertPlaceholders, isProjectIcuEnabled, paramConvertorFactory) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoCToIcuImportMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoCToIcuImportMessageConvertor.kt new file mode 100644 index 0000000000..120a1b1647 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoCToIcuImportMessageConvertor.kt @@ -0,0 +1,21 @@ +package io.tolgee.formats.po.`in`.messageConvertors + +import io.tolgee.formats.ImportMessageConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.po.`in`.paramConvertors.CToIcuParamConvertor + +class PoCToIcuImportMessageConvertor : ImportMessageConvertor { + override fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + return BasePoToIcuMessageConvertor { CToIcuParamConvertor() }.convert( + rawData, + languageTag, + convertPlaceholders, + isProjectIcuEnabled, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPhpToIcuImportMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPhpToIcuImportMessageConvertor.kt new file mode 100644 index 0000000000..e1415900c6 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPhpToIcuImportMessageConvertor.kt @@ -0,0 +1,21 @@ +package io.tolgee.formats.po.`in`.messageConvertors + +import io.tolgee.formats.ImportMessageConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.po.`in`.paramConvertors.PhpToIcuParamConvertor + +class PoPhpToIcuImportMessageConvertor : ImportMessageConvertor { + override fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + return BasePoToIcuMessageConvertor { PhpToIcuParamConvertor() }.convert( + rawData, + languageTag, + convertPlaceholders, + isProjectIcuEnabled, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPythonToIcuImportMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPythonToIcuImportMessageConvertor.kt new file mode 100644 index 0000000000..b172bfc5ac --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/messageConvertors/PoPythonToIcuImportMessageConvertor.kt @@ -0,0 +1,21 @@ +package io.tolgee.formats.po.`in`.messageConvertors + +import io.tolgee.formats.ImportMessageConvertor +import io.tolgee.formats.MessageConvertorResult +import io.tolgee.formats.po.`in`.paramConvertors.PythonToIcuParamConvertor + +class PoPythonToIcuImportMessageConvertor : ImportMessageConvertor { + override fun convert( + rawData: Any?, + languageTag: String, + convertPlaceholders: Boolean, + isProjectIcuEnabled: Boolean, + ): MessageConvertorResult { + return BasePoToIcuMessageConvertor { PythonToIcuParamConvertor() }.convert( + rawData, + languageTag, + convertPlaceholders, + isProjectIcuEnabled, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/CToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/CToIcuParamConvertor.kt new file mode 100644 index 0000000000..88595c7481 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/CToIcuParamConvertor.kt @@ -0,0 +1,56 @@ +package io.tolgee.formats.po.`in`.paramConvertors + +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertFloatToIcu +import io.tolgee.formats.escapeIcu +import io.tolgee.formats.po.`in`.CLikeParameterParser +import io.tolgee.formats.usesUnsupportedFeature + +class CToIcuParamConvertor : ToIcuParamConvertor { + private val parser = CLikeParameterParser() + private var index = 0 + + override val regex: Regex + get() = C_PARAM_REGEX + + override fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String { + val parsed = parser.parse(matchResult) ?: return matchResult.value.escapeIcu(isInPlural) + + if (usesUnsupportedFeature(parsed)) { + return matchResult.value.escapeIcu(isInPlural) + } + + if (parsed.specifier == "%") { + return "%" + } + + index++ + val name = ((index - 1).toString()) + + when (parsed.specifier) { + "s" -> return "{$name}" + "d" -> return "{$name, number}" + "e" -> return "{$name, number, scientific}" + "f" -> return convertFloatToIcu(parsed, name) ?: parsed.fullMatch.escapeIcu(isInPlural) + } + + return matchResult.value.escapeIcu(isInPlural) + } + + companion object { + val C_PARAM_REGEX = + """ + (?x)( + % + (?[-+\s0\#]+)? + (?\d+)? + (?:\.(?\d+))? + (?hh|h|l|ll|j|z|t|L)? + (?[diuoxXfFeEgGaAcspn%]) + ) + """.trimIndent().toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PhpToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PhpToIcuParamConvertor.kt new file mode 100644 index 0000000000..f540c77890 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PhpToIcuParamConvertor.kt @@ -0,0 +1,57 @@ +package io.tolgee.formats.po.`in`.paramConvertors + +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertFloatToIcu +import io.tolgee.formats.escapeIcu +import io.tolgee.formats.po.`in`.CLikeParameterParser +import io.tolgee.formats.usesUnsupportedFeature + +class PhpToIcuParamConvertor : ToIcuParamConvertor { + private val parser = CLikeParameterParser() + private var index = 0 + + override val regex: Regex + get() = PHP_PARAM_REGEX + + override fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String { + val parsed = parser.parse(matchResult) ?: return matchResult.value.escapeIcu(isInPlural) + + if (usesUnsupportedFeature(parsed)) { + return matchResult.value.escapeIcu(isInPlural) + } + + if (parsed.specifier == "%") { + return "%" + } + + index++ + val zeroIndexedArgNum = parsed.argNum?.toIntOrNull()?.minus(1)?.toString() + val name = zeroIndexedArgNum ?: ((index - 1).toString()) + + when (parsed.specifier) { + "s" -> return "{$name}" + "d" -> return "{$name, number}" + "e" -> return "{$name, number, scientific}" + "f" -> return convertFloatToIcu(parsed, name) ?: matchResult.value.escapeIcu(isInPlural) + } + + return matchResult.value.escapeIcu(isInPlural) + } + + companion object { + val PHP_PARAM_REGEX = + """ + (?x)( + % + (?:(?\d+)${"\\$"})? + (?(?:[-+\s0]|'.)+)? + (?\d+)? + (?:\.(?\d+))? + (?[bcdeEfFgGhHosuxX%]) + ) + """.trimIndent().toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PythonToIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PythonToIcuParamConvertor.kt new file mode 100644 index 0000000000..69fd77c21e --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/paramConvertors/PythonToIcuParamConvertor.kt @@ -0,0 +1,55 @@ +package io.tolgee.formats.po.`in`.paramConvertors + +import io.tolgee.formats.ToIcuParamConvertor +import io.tolgee.formats.convertFloatToIcu +import io.tolgee.formats.escapeIcu +import io.tolgee.formats.po.`in`.CLikeParameterParser +import io.tolgee.formats.usesUnsupportedFeature + +class PythonToIcuParamConvertor : ToIcuParamConvertor { + private val parser = CLikeParameterParser() + + override val regex: Regex + get() = PYTHON_PARAM_REGEX + + override fun convert( + matchResult: MatchResult, + isInPlural: Boolean, + ): String { + val parsed = parser.parse(matchResult) ?: return matchResult.value.escapeIcu(isInPlural) + + if (usesUnsupportedFeature(parsed)) { + return matchResult.value.escapeIcu(isInPlural) + } + + if (parsed.specifier == "%") { + return "%" + } + + val argName = parsed.argName ?: throw IllegalArgumentException("Python spec requires named arguments") + + when (parsed.specifier) { + "s" -> return "{$argName}" + "d" -> return "{$argName, number}" + "f" -> return convertFloatToIcu(parsed, argName) ?: return matchResult.value.escapeIcu(isInPlural) + "e" -> return "{$argName, number, scientific}" + } + + return "{$argName}" + } + + companion object { + val PYTHON_PARAM_REGEX = + """ + (?x)( + % + (?:\((?[\w-]+)\))? + (?[-+\s0\#]+)? + (?[\d*]+)? + (?:\.(?\d+))? + (?[hlL])? + (?[diouxXeEfFgGcrs%]) + ) + """.trimIndent().toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/BaseIcuMessageToPoConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/BaseIcuMessageToPoConvertor.kt new file mode 100644 index 0000000000..bbf0ddeb44 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/BaseIcuMessageToPoConvertor.kt @@ -0,0 +1,109 @@ +package io.tolgee.formats.po.out + +import com.ibm.icu.text.PluralRules +import com.ibm.icu.text.PluralRules.FixedDecimal +import com.ibm.icu.util.ULocale +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessageConvertorFactory +import io.tolgee.formats.escaping.IcuUnescper +import io.tolgee.formats.getPluralDataOrNull +import io.tolgee.formats.getULocaleFromTag +import io.tolgee.formats.pluralData.PluralData + +class BaseIcuMessageToPoConvertor( + val message: String, + val argumentConverter: FromIcuParamConvertor, + val languageTag: String = "en", + private val forceIsPlural: Boolean, + private val projectIcuPlaceholdersSupport: Boolean = true, +) { + companion object { + const val OTHER_KEYWORD = "other" + } + + private val locale: ULocale by lazy { + getULocaleFromTag(languageTag) + } + + private val languagePluralData by lazy { + getPluralDataOrNull(locale) ?: let { + PluralData.DATA["en"]!! + } + } + + fun convert(): ToPoConversionResult { + if (!forceIsPlural) { + return getSingularResult() + } + + return getPluralResult() + } + + private fun getPluralResult(): ToPoConversionResult { + val result = + MessageConvertorFactory( + message, + forceIsPlural, + projectIcuPlaceholdersSupport, + ) { + argumentConverter + }.create().convert() + val poPluralResult = getPluralResult(result.formsResult ?: mutableMapOf()) + return ToPoConversionResult(null, poPluralResult) + } + + private fun getSingularResult(): ToPoConversionResult { + val result = + MessageConvertorFactory( + message, + forceIsPlural = false, + projectIcuPlaceholdersSupport, + ) { + argumentConverter + }.create().convert() + return ToPoConversionResult(result.singleResult, null) + } + + private fun getPluralResult(formsResult: Map): List { + val forms = getPluralForms(formsResult) + val plurals = + languagePluralData.examples.map { + val form = forms[it.plural] ?: OTHER_KEYWORD + it.plural to ((formsResult[form] ?: formsResult[OTHER_KEYWORD])?.forceUnescape() ?: "") + }.sortedBy { it.first }.map { it.second }.toList() + + return plurals + } + + private fun getPluralForms(pluralFormsResult: Map): Map { + val pluralIndexes = + pluralFormsResult + .map { it.key to getPluralIndexesForKeyword(it.key) }.toMap() + + val allIndexes = pluralIndexes.flatMap { it.value }.toSet() + return allIndexes.mapNotNull { index -> + val keyword = + pluralIndexes.entries + // We need to find keyword which contains only this index, because "other" keyword matches all + .find { entry -> entry.value.contains(index) && entry.value.size == 1 }?.key + ?: pluralIndexes.entries.find { entry -> + entry.value.contains(index) + }?.key ?: return@mapNotNull null + index to keyword + }.toMap() + } + + private fun String.forceUnescape(): String { + if (!projectIcuPlaceholdersSupport) { + return IcuUnescper(this).unescaped + } + return this + } + + private fun getPluralIndexesForKeyword(keyword: String) = + languagePluralData.examples.filter { + // This is probably only way how to do it, so we have to use internal API + @Suppress("DEPRECATION") + PluralRules.forLocale(locale).matches(FixedDecimal(it.sample.toLong()), keyword) + }.map { it.plural } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt new file mode 100644 index 0000000000..9f51f55f25 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt @@ -0,0 +1,107 @@ +package io.tolgee.formats.po.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.getPluralData +import io.tolgee.formats.po.PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.model.ILanguage +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class PoFileExporter( + override val translations: List, + override val exportParams: IExportParams, + baseTranslationsProvider: () -> List, + val baseLanguage: ILanguage, + private val poSupportedMessageFormat: PoSupportedMessageFormat, + private val projectIcuPlaceholdersSupport: Boolean = true, +) : FileExporter { + override val fileExtension: String = "po" + + private val preparedResult: LinkedHashMap = LinkedHashMap() + + override fun produceFiles(): Map { + prepareResult() + return preparedResult.asSequence().map { (fileName, content) -> + fileName to content.toString().byteInputStream() + }.toMap() + } + + private fun prepareResult() { + translations.forEach { translation -> + val resultBuilder = getResultStringBuilder(translation) + val converted = + poSupportedMessageFormat.exportMessageConverter( + translation.text!!, + translation.languageTag, + translation.key.isPlural, + projectIcuPlaceholdersSupport, + ).convert() + + resultBuilder.appendLine() + resultBuilder.writeMsgId(translation.key.name) + resultBuilder.writeMsgIdPlural(translation, converted) + resultBuilder.writeMsgStr(converted) + } + } + + private fun StringBuilder.writeMsgId(keyName: String) { + this.append(convertToPoMultilineString("msgid", keyName)) + } + + private fun getResultStringBuilder(translation: ExportTranslationView): StringBuilder { + val path = translation.getFilePath() + return preparedResult.computeIfAbsent(path) { + initPoFile(translation) + } + } + + private fun initPoFile(translation: ExportTranslationView): StringBuilder { + val builder = StringBuilder() + val pluralData = getPluralData(translation.languageTag) + builder.appendLine("msgid \"\"") + builder.appendLine("msgstr \"\"") + builder.appendLine("\"Language: ${translation.languageTag}\\n\"") + builder.appendLine("\"MIME-Version: 1.0\\n\"") + builder.appendLine("\"Content-Type: text/plain; charset=UTF-8\\n\"") + builder.appendLine("\"Content-Transfer-Encoding: 8bit\\n\"") + builder.appendLine("\"Plural-Forms: ${pluralData.pluralsText}\\n\"") + builder.appendLine("\"X-Generator: Tolgee\\n\"") + return builder + } + + private fun StringBuilder.writeMsgStr(converted: ToPoConversionResult) { + if (converted.isPlural()) { + writePlural(converted.formsResult) + return + } + + writeSingle(converted.singleResult) + } + + private fun StringBuilder.writePlural(forms: List?) { + forms?.forEachIndexed { index, form -> + this.append(convertToPoMultilineString("msgstr[$index]", form)) + } + } + + private fun StringBuilder.writeSingle(result: String?) { + this.append(convertToPoMultilineString("msgstr", result ?: "")) + } + + private fun StringBuilder.writeMsgIdPlural( + translation: ExportTranslationView, + converted: ToPoConversionResult, + ) { + val msgIdPlural = translation.key.custom?.get(PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY) as? String ?: return + if (converted.isPlural()) { + this.append( + convertToPoMultilineString( + "msgid_plural", + msgIdPlural, + ), + ) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoConversionResult.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoConversionResult.kt new file mode 100644 index 0000000000..88352cc172 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoConversionResult.kt @@ -0,0 +1,16 @@ +package io.tolgee.formats.po.out + +class ToPoConversionResult( + val singleResult: String?, + val formsResult: List?, +) { + init { + if (singleResult == null && formsResult == null) { + throw IllegalArgumentException("Both result and forms cannot be null") + } + } + + fun isPlural(): Boolean { + return formsResult != null + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoMessageConvertor.kt new file mode 100644 index 0000000000..69fb17d242 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/ToPoMessageConvertor.kt @@ -0,0 +1,5 @@ +package io.tolgee.formats.po.out + +interface ToPoMessageConvertor { + fun convert(): ToPoConversionResult +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/CFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/CFromIcuParamConvertor.kt new file mode 100644 index 0000000000..7652c9a85c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/CFromIcuParamConvertor.kt @@ -0,0 +1,57 @@ +package io.tolgee.formats.po.out.c + +import com.ibm.icu.text.MessagePattern +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class CFromIcuParamConvertor : FromIcuParamConvertor { + private var argIndex = -1 + + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + argIndex++ + val type = node.argType + + if (type == MessagePattern.ArgType.SIMPLE) { + when (node.typeName) { + "number" -> return convertNumber(node) + } + } + + return "%s" + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return "%d" + } + + private fun convertNumber(node: MessagePatternUtil.ArgNode): String { + if (node.simpleStyle?.trim() == "scientific") { + return "%e" + } + val precision = getPrecision(node) + if (precision == 6) { + return "%f" + } + if (precision != null) { + return "%.${precision}f" + } + + return "%d" + } + + private fun getPrecision(node: MessagePatternUtil.ArgNode): Int? { + val precisionMatch = ICU_PRECISION_REGEX.matchEntire(node.simpleStyle ?: "") + precisionMatch ?: return null + return precisionMatch.groups["precision"]?.value?.length + } + + companion object { + val ICU_PRECISION_REGEX = """.*\.(?0+)""".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/ToCPoMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/ToCPoMessageConvertor.kt new file mode 100644 index 0000000000..a8612666d5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/c/ToCPoMessageConvertor.kt @@ -0,0 +1,25 @@ +package io.tolgee.formats.po.out.c + +import io.tolgee.formats.po.out.BaseIcuMessageToPoConvertor +import io.tolgee.formats.po.out.ToPoConversionResult +import io.tolgee.formats.po.out.ToPoMessageConvertor + +class ToCPoMessageConvertor( + val message: String, + val languageTag: String = "en", + forceIsPlural: Boolean, + projectIcuPlaceholdersSupport: Boolean = true, +) : ToPoMessageConvertor { + private val baseIcuMessageToClikeConvertor = + BaseIcuMessageToPoConvertor( + message = message, + languageTag = languageTag, + argumentConverter = CFromIcuParamConvertor(), + forceIsPlural = forceIsPlural, + projectIcuPlaceholdersSupport = projectIcuPlaceholdersSupport, + ) + + override fun convert(): ToPoConversionResult { + return baseIcuMessageToClikeConvertor.convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/messageToMultilineConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/messageToMultilineConvertor.kt new file mode 100644 index 0000000000..e4fe111e44 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/messageToMultilineConvertor.kt @@ -0,0 +1,29 @@ +package io.tolgee.formats.po.out + +fun convertToPoMultilineString( + keyword: String, + text: String, +): String { + // Normalize newlines and split the text on newline characters. + val lines = + text + .replace("\r\n", "\n") + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .split("\n") + + if (lines.size == 1) { + return "$keyword \"${lines[0]}\"\n" + } + + return buildString { + append("$keyword \"\"\n") + for ((index, line) in lines.withIndex()) { + if (index != lines.size - 1) { + append("\"$line\\n\"\n") + } else { + append("\"$line\"\n") + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/PhpFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/PhpFromIcuParamConvertor.kt new file mode 100644 index 0000000000..270df6956b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/PhpFromIcuParamConvertor.kt @@ -0,0 +1,71 @@ +package io.tolgee.formats.po.out.php + +import com.ibm.icu.text.MessagePattern +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class PhpFromIcuParamConvertor : FromIcuParamConvertor { + private var argIndex = -1 + private var wasNumberedArg = false + + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + argIndex++ + val argNum = node.name?.toIntOrNull() + val argNumString = getArgNumString(argNum) + val type = node.argType + + if (type == MessagePattern.ArgType.SIMPLE) { + when (node.typeName) { + "number" -> return convertNumber(node, argNum) + } + } + + return "%${argNumString}s" + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return "%d" + } + + private fun convertNumber( + node: MessagePatternUtil.ArgNode, + argNum: Int?, + ): String { + if (node.simpleStyle?.trim() == "scientific") { + return "%${getArgNumString(argNum)}e" + } + val precision = getPrecision(node) + if (precision == 6) { + return "%${getArgNumString(argNum)}f" + } + if (precision != null) { + return "%${getArgNumString(argNum)}.${precision}f" + } + + return "%${getArgNumString(argNum)}d" + } + + private fun getPrecision(node: MessagePatternUtil.ArgNode): Int? { + val precisionMatch = ICU_PRECISION_REGEX.matchEntire(node.simpleStyle ?: "") + precisionMatch ?: return null + return precisionMatch.groups["precision"]?.value?.length + } + + private fun getArgNumString(icuArgNum: Int?): String { + if ((icuArgNum != argIndex || wasNumberedArg) && icuArgNum != null) { + wasNumberedArg = true + return "${icuArgNum + 1}$" + } + return "" + } + + companion object { + val ICU_PRECISION_REGEX = """.*\.(?0+)""".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/ToPhpPoMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/ToPhpPoMessageConvertor.kt new file mode 100644 index 0000000000..03affddeb3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/php/ToPhpPoMessageConvertor.kt @@ -0,0 +1,25 @@ +package io.tolgee.formats.po.out.php + +import io.tolgee.formats.po.out.BaseIcuMessageToPoConvertor +import io.tolgee.formats.po.out.ToPoConversionResult +import io.tolgee.formats.po.out.ToPoMessageConvertor + +class ToPhpPoMessageConvertor( + val message: String, + val languageTag: String = "en", + forceIsPlural: Boolean, + projectIcuPlaceholdersSupport: Boolean = true, +) : ToPoMessageConvertor { + private val baseIcuMessageToPoConvertor = + BaseIcuMessageToPoConvertor( + message = message, + languageTag = languageTag, + argumentConverter = PhpFromIcuParamConvertor(), + forceIsPlural = forceIsPlural, + projectIcuPlaceholdersSupport = projectIcuPlaceholdersSupport, + ) + + override fun convert(): ToPoConversionResult { + return baseIcuMessageToPoConvertor.convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/PythonFromIcuParamConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/PythonFromIcuParamConvertor.kt new file mode 100644 index 0000000000..e108832e1b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/PythonFromIcuParamConvertor.kt @@ -0,0 +1,62 @@ +package io.tolgee.formats.po.out.python + +import com.ibm.icu.text.MessagePattern +import io.tolgee.formats.FromIcuParamConvertor +import io.tolgee.formats.MessagePatternUtil + +class PythonFromIcuParamConvertor : FromIcuParamConvertor { + private var argIndex = -1 + + override fun convert( + node: MessagePatternUtil.ArgNode, + isInPlural: Boolean, + ): String { + argIndex++ + val argNumString = getArgNameString(node) + val type = node.argType + + if (type == MessagePattern.ArgType.SIMPLE) { + when (node.typeName) { + "number" -> return convertNumber(node) + } + } + + return "%${argNumString}s" + } + + override fun convertReplaceNumber( + node: MessagePatternUtil.MessageContentsNode, + argName: String?, + ): String { + return "%($argName)d" + } + + private fun convertNumber(node: MessagePatternUtil.ArgNode): String { + if (node.simpleStyle?.trim() == "scientific") { + return "%${getArgNameString(node)}e" + } + val precision = getPrecision(node) + if (precision == 6) { + return "%${getArgNameString(node)}f" + } + if (precision != null) { + return "%${getArgNameString(node)}.${precision}f" + } + + return "%${getArgNameString(node)}d" + } + + private fun getPrecision(node: MessagePatternUtil.ArgNode): Int? { + val precisionMatch = ICU_PRECISION_REGEX.matchEntire(node.simpleStyle ?: "") + precisionMatch ?: return null + return precisionMatch.groups["precision"]?.value?.length + } + + private fun getArgNameString(node: MessagePatternUtil.ArgNode): String { + return "(${node.name})" + } + + companion object { + val ICU_PRECISION_REGEX = """.*\.(?0+)""".toRegex() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/ToPythonPoMessageConvertor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/ToPythonPoMessageConvertor.kt new file mode 100644 index 0000000000..58f0ea8a2d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/python/ToPythonPoMessageConvertor.kt @@ -0,0 +1,25 @@ +package io.tolgee.formats.po.out.python + +import io.tolgee.formats.po.out.BaseIcuMessageToPoConvertor +import io.tolgee.formats.po.out.ToPoConversionResult +import io.tolgee.formats.po.out.ToPoMessageConvertor + +class ToPythonPoMessageConvertor( + val message: String, + val languageTag: String = "en", + forceIsPlural: Boolean, + projectIcuPlaceholdersSupport: Boolean = true, +) : ToPoMessageConvertor { + private val baseIcuMessageToClikeConvertor = + BaseIcuMessageToPoConvertor( + message = message, + languageTag = languageTag, + argumentConverter = PythonFromIcuParamConvertor(), + forceIsPlural = forceIsPlural, + projectIcuPlaceholdersSupport = projectIcuPlaceholdersSupport, + ) + + override fun convert(): ToPoConversionResult { + return baseIcuMessageToClikeConvertor.convert() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/properties/in/PropertiesFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/properties/in/PropertiesFileProcessor.kt new file mode 100644 index 0000000000..3a4c29673c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/properties/in/PropertiesFileProcessor.kt @@ -0,0 +1,30 @@ +package io.tolgee.formats.properties.`in` + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.service.dataImport.processors.FileProcessorContext +import org.apache.commons.configuration2.PropertiesConfiguration +import org.apache.commons.configuration2.io.FileHandler +import java.util.* + +class PropertiesFileProcessor( + override val context: FileProcessorContext, +) : ImportFileProcessor() { + override fun process() { + val config = PropertiesConfiguration() + val handler = FileHandler(config) + handler.load(context.file.data.inputStream()) + try { + config.keys.asSequence().forEachIndexed { idx, key -> + val value = config.getString(key) + val comment = config.layout.getCanonicalComment(key, false) + if (!comment.isNullOrBlank()) { + context.addKeyDescription(key, comment) + } + context.addGenericFormatTranslation(key, languageNameGuesses[0], value, idx) + } + } catch (e: Exception) { + throw ImportCannotParseFileException(context.file.name, e.message) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/properties/out/PropertiesFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/properties/out/PropertiesFileExporter.kt new file mode 100644 index 0000000000..c8587eb03c --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/properties/out/PropertiesFileExporter.kt @@ -0,0 +1,93 @@ +package io.tolgee.formats.properties.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import org.apache.commons.configuration2.PropertiesConfiguration +import org.apache.commons.configuration2.PropertiesConfiguration.DefaultIOFactory +import org.apache.commons.configuration2.PropertiesConfiguration.PropertiesWriter +import org.apache.commons.configuration2.convert.ListDelimiterHandler +import org.apache.commons.configuration2.convert.ValueTransformer +import org.apache.commons.text.translate.AggregateTranslator +import org.apache.commons.text.translate.EntityArrays +import org.apache.commons.text.translate.LookupTranslator +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.io.StringWriter +import java.io.Writer + +class PropertiesFileExporter( + override val translations: List, + override val exportParams: IExportParams, + val convertMessage: (message: String?, isPlural: Boolean) -> String? = { message, _ -> message }, +) : FileExporter { + override val fileExtension: String = "properties" + + val result: MutableMap = mutableMapOf() + + private fun prepare() { + translations.forEach { translation -> + val fileName = computeFileName(translation) + val keyName = translation.key.name + val value = convertMessage(translation.text, translation.key.isPlural) + val properties = result.getOrPut(fileName) { PropertiesConfiguration() } + properties.setProperty(keyName, value) + properties.layout.setComment(keyName, translation.key.description) + } + } + + override fun produceFiles(): Map { + prepare() + return result.asSequence().map { (fileName, properties) -> + // convert properties to bytes + val bytes = properties.asByteArray() + fileName to ByteArrayInputStream(bytes) + }.toMap() + } + + private fun PropertiesConfiguration.asByteArray(): ByteArray { + val writer = StringWriter() + this.ioFactory = Utf8IoFactory() + this.write(writer) + return writer.toString().toByteArray() + } + + private fun computeFileName(translation: ExportTranslationView): String { + return translation.getFilePath() + } +} + +/** + * This class is custom implementation of the [ValueTransformer] that prevents escaping of UTF-8 characters + * UTF-8 is supported in properties files by Java 9 + */ +private class Utf8ValueTransformer : ValueTransformer { + companion object { + private val charsEscape = mapOf("\\" to "\\\\") + private val escapeProperties = + AggregateTranslator( + LookupTranslator(charsEscape), + LookupTranslator( + EntityArrays.JAVA_CTRL_CHARS_ESCAPE, + ), + ) + } + + override fun transformValue(p0: Any?): Any { + val strVal = p0.toString() + return escapeProperties.translate(strVal) + } +} + +private class Utf8IoFactory : DefaultIOFactory() { + companion object { + private val valueTransformer = Utf8ValueTransformer() + } + + override fun createPropertiesWriter( + out: Writer?, + handler: ListDelimiterHandler?, + ): PropertiesWriter { + return PropertiesWriter(out, handler, valueTransformer) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/replaceMatchedAndNotMatched.kt b/backend/data/src/main/kotlin/io/tolgee/formats/replaceMatchedAndNotMatched.kt new file mode 100644 index 0000000000..17b388ed76 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/replaceMatchedAndNotMatched.kt @@ -0,0 +1,26 @@ +package io.tolgee.formats + +/** + * Unlike the [String.replace] method, this method allows to replace both matched and unmatched parts of the string. + */ +fun String.replaceMatchedAndUnmatched( + string: String, + regex: Regex, + matchedCallback: (MatchResult) -> String, + unmatchedCallback: (String) -> String, +): String { + var lastIndex = 0 + val result = StringBuilder() + + for (match in regex.findAll(string)) { + val unmatchedPart = string.substring(lastIndex until match.range.first) + result.append(unmatchedCallback(unmatchedPart)) + result.append(matchedCallback(match)) + lastIndex = match.range.last + 1 + } + + val finalUnmatchedPart = string.substring(lastIndex) + result.append(unmatchedCallback(finalUnmatchedPart)) + + return result.toString() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/Xliff12FileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/Xliff12FileProcessor.kt new file mode 100644 index 0000000000..c760c3193a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/Xliff12FileProcessor.kt @@ -0,0 +1,53 @@ +package io.tolgee.formats.xliff.`in` + +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType +import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class Xliff12FileProcessor( + override val context: FileProcessorContext, + private val parsed: XliffModel, +) : ImportFileProcessor() { + override fun process() { + parsed.files.forEach { file -> + file.transUnits.forEach transUnitsForeach@{ transUnit -> + val fileOriginal = file.original + val transUnitId = + transUnit.id ?: let { + context.fileEntity.addIssue( + FileIssueType.ID_ATTRIBUTE_NOT_PROVIDED, + mapOf(FileIssueParamType.FILE_NODE_ORIGINAL to (fileOriginal ?: "")), + ) + return@transUnitsForeach + } + if (!fileOriginal.isNullOrBlank()) { + context.addKeyCodeReference(transUnitId, fileOriginal, null) + } + transUnit.source?.let { source -> + context.addGenericFormatTranslation( + transUnitId, + file.sourceLanguage ?: "unknown source", + source, + ) + } + + transUnit.target?.let { target -> + context.addGenericFormatTranslation( + transUnitId, + file.targetLanguage ?: "unknown target", + target, + ) + } ?: let { + context.fileEntity.addIssue( + FileIssueType.TARGET_NOT_PROVIDED, + mapOf(FileIssueParamType.KEY_NAME to transUnitId), + ) + } + + transUnit.note?.let { context.addKeyDescription(transUnitId, it) } + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/XliffFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/XliffFileProcessor.kt new file mode 100644 index 0000000000..a745cf3368 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/XliffFileProcessor.kt @@ -0,0 +1,46 @@ +package io.tolgee.formats.xliff.`in` + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.exceptions.UnsupportedXliffVersionException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.apple.`in`.xliff.AppleXliffFileProcessor +import io.tolgee.formats.xliff.`in`.parser.XliffParser +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.service.dataImport.processors.FileProcessorContext +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLInputFactory + +class XliffFileProcessor(override val context: FileProcessorContext) : ImportFileProcessor() { + override fun process() { + val parsed = + try { + XliffParser(xmlEventReader).parse() + } catch (e: ImportCannotParseFileException) { + throw ImportCannotParseFileException(context.file.name, e.message) + } + if (isApple(parsed)) { + return AppleXliffFileProcessor(context, parsed).process() + } + + try { + val version = parsed.version ?: throw ImportCannotParseFileException(context.file.name, "Version not found") + when (version) { + "1.2" -> Xliff12FileProcessor(context, parsed).process() + else -> throw UnsupportedXliffVersionException(version) + } + } catch (e: Exception) { + throw ImportCannotParseFileException(context.file.name, e.message) + } + } + + private fun isApple(parsed: XliffModel): Boolean { + return parsed.files.any { + it.original?.matches(Regex(".*\\.(?:xc)?strings(?:dict)?$")) == true + } + } + + private val xmlEventReader: XMLEventReader by lazy { + val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() + inputFactory.createXMLEventReader(context.file.data.inputStream()) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/parser/XliffParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/parser/XliffParser.kt new file mode 100644 index 0000000000..4821da6bd2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/in/parser/XliffParser.kt @@ -0,0 +1,169 @@ +package io.tolgee.formats.xliff.`in`.parser + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.xliff.model.XliffFile +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.formats.xliff.model.XliffTransUnit +import java.io.StringWriter +import java.util.* +import javax.xml.XMLConstants +import javax.xml.namespace.QName +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLEventWriter +import javax.xml.stream.XMLOutputFactory +import javax.xml.stream.events.StartElement + +class XliffParser( + private val xmlEventReader: XMLEventReader, +) { + private var sw = StringWriter() + private val of: XMLOutputFactory = XMLOutputFactory.newDefaultFactory() + private var xw: XMLEventWriter? = null + private val openElements = mutableListOf("xliff") + private val currentOpenElement: String? + get() = openElements.lastOrNull() + + private val result = XliffModel() + private var currentFile: XliffFile? = null + private var currentTransUnit: XliffTransUnit? = null + private var preservingSpaces = mutableListOf() + + fun parse(): XliffModel { + try { + return doParse() + } catch (e: Exception) { + throw ImportCannotParseFileException("XLIFF", e.message) + } finally { + xmlEventReader.close() + } + } + + private fun doParse(): XliffModel { + parseVersion() + while (xmlEventReader.hasNext()) { + val event = xmlEventReader.nextEvent() + when { + event.isStartElement -> { + if (!isAnyToContentSaveOpen) { + sw = StringWriter() + xw = of.createXMLEventWriter(sw) + } + (event as? StartElement)?.let { startElement -> + preservingSpaces.add(getCurrentElementPreserveSpaces(startElement)) + openElements.add(startElement.name.localPart.lowercase(Locale.getDefault())) + when (currentOpenElement) { + "file" -> { + val file = XliffFile() + currentFile = file + result.files.add(file) + file.original = + startElement + .getAttributeByName(QName(null, "original"))?.value + file.sourceLanguage = + startElement + .getAttributeByName(QName(null, "source-language"))?.value + file.targetLanguage = + startElement + .getAttributeByName(QName(null, "target-language"))?.value + } + + "trans-unit" -> { + if (currentFile == null) { + throw IllegalStateException("Unexpected trans-unit element") + } + val transUnit = XliffTransUnit() + currentTransUnit = transUnit + currentFile!!.transUnits.add(transUnit) + transUnit.id = startElement.getAttributeByName(QName(null, "id"))?.value + transUnit.translate = startElement.getAttributeByName(QName(null, "translate"))?.value + } + } + } + } + + event.isEndElement -> { + when (currentOpenElement) { + "file" -> { + currentFile = null + } + + "trans-unit" -> { + currentTransUnit = null + } + + "source" -> { + currentTransUnit?.let { + it.source = getCurrentSwString() + } + } + + "target" -> { + currentTransUnit?.let { + it.target = getCurrentSwString() + } + } + + "note" -> { + currentTransUnit?.let { + it.note = getCurrentSwString() + } + } + } + openElements.removeLast() + preservingSpaces.removeLast() + } + } + + if (isAnyToContentSaveOpen) { + val startName = (event as? StartElement)?.name?.localPart?.lowercase(Locale.getDefault()) + if (!CONTENT_SAVE_ELEMENTS.contains(startName)) { + xw?.add(event) + } + } else { + xw?.close() + } + } + return result + } + + private fun getCurrentElementPreserveSpaces(startElement: StartElement): Boolean? { + val value = startElement.getAttributeByName(QName(XMLConstants.XML_NS_URI, "space"))?.value + return when (value) { + "preserve" -> true + "default" -> false + else -> null + } + } + + private fun getCurrentSwString(): String { + val result = sw.toString() + val preserveNamespace = preservingSpaces.lastOrNull { it != null } ?: false + if (!preserveNamespace) { + return result.trim() + } + return result + } + + private fun parseVersion() { + while (xmlEventReader.hasNext()) { + val event = xmlEventReader.nextEvent() + if (event.isStartElement && + (event as? StartElement)?.name?.localPart?.lowercase(Locale.getDefault()) == "xliff" + ) { + preservingSpaces.add(getCurrentElementPreserveSpaces(event)) + val versionAttr = event.getAttributeByName(QName(null, "version")) + if (versionAttr != null) { + result.version = versionAttr.value + return + } + } + } + } + + private val isAnyToContentSaveOpen + get() = openElements.any { CONTENT_SAVE_ELEMENTS.contains(it) } + + companion object { + private val CONTENT_SAVE_ELEMENTS = listOf("source", "target", "note") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffFile.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffFile.kt new file mode 100644 index 0000000000..e1c9eee558 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffFile.kt @@ -0,0 +1,28 @@ +package io.tolgee.formats.xliff.model + +class XliffFile { + val transUnits = mutableListOf() + var original: String? = null + var sourceLanguage: String? = null + var targetLanguage: String? = null + val datatype: String = "plaintext" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as XliffFile + + if (original != other.original) return false + if (sourceLanguage != other.sourceLanguage) return false + if (targetLanguage != other.targetLanguage) return false + + return true + } + + override fun hashCode(): Int { + var result = sourceLanguage?.hashCode() ?: 0 + result = 31 * result + (targetLanguage?.hashCode() ?: 0) + return result + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffModel.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffModel.kt new file mode 100644 index 0000000000..4e71a1b7f4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffModel.kt @@ -0,0 +1,6 @@ +package io.tolgee.formats.xliff.model + +class XliffModel { + val files = mutableListOf() + var version: String? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffTransUnit.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffTransUnit.kt new file mode 100644 index 0000000000..d1b9b58336 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/model/XliffTransUnit.kt @@ -0,0 +1,9 @@ +package io.tolgee.formats.xliff.model + +class XliffTransUnit { + var id: String? = null + var source: String? = null + var target: String? = null + var note: String? = null + var translate: String? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileExporter.kt new file mode 100644 index 0000000000..2245c38a01 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileExporter.kt @@ -0,0 +1,75 @@ +package io.tolgee.formats.xliff.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.xliff.model.XliffFile +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.formats.xliff.model.XliffTransUnit +import io.tolgee.model.ILanguage +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class XliffFileExporter( + override val translations: List, + override val exportParams: IExportParams, + baseTranslationsProvider: () -> List, + val baseLanguage: ILanguage, + val convertMessage: (message: String?, isPlural: Boolean) -> String? = { message, _ -> message }, +) : FileExporter { + override val fileExtension: String = ExportFormat.XLIFF.extension + + /** + * Path -> Xliff Model + */ + val models = mutableMapOf() + private val baseTranslations by lazy { + baseTranslationsProvider().associateBy { it.key.namespace to it.key.name } + } + + override fun produceFiles(): Map { + prepare() + return models.asSequence().map { (fileName, resultItem) -> + fileName to XliffFileWriter(xliffModel = resultItem, enableXmlContent = true).produceFiles() + }.toMap() + } + + private fun prepare() { + translations.forEach { translation -> + val resultItem = getResultXliffFile(translation) + addTranslation(resultItem, translation) + } + } + + private fun addTranslation( + resultItem: XliffFile, + translation: ExportTranslationView, + ) { + resultItem.transUnits.add( + XliffTransUnit().apply { + this.id = translation.key.name + this.source = + convertMessage( + baseTranslations[translation.key.namespace to translation.key.name]?.text, + translation.key.isPlural, + ) + this.target = convertMessage(translation.text, translation.key.isPlural) + this.note = translation.key.description + }, + ) + } + + private fun getResultXliffFile(translation: ExportTranslationView): XliffFile { + val absolutePath = translation.getFilePath() + return models.computeIfAbsent(absolutePath) { + XliffModel().apply { + files.add( + XliffFile().apply { + this.sourceLanguage = baseLanguage.tag + this.targetLanguage = translation.languageTag + }, + ) + } + }.files.first() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileWriter.kt new file mode 100644 index 0000000000..58a2ba3c17 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/xliff/out/XliffFileWriter.kt @@ -0,0 +1,85 @@ +package io.tolgee.formats.xliff.out + +import io.tolgee.formats.xliff.model.XliffFile +import io.tolgee.formats.xliff.model.XliffModel +import io.tolgee.formats.xliff.model.XliffTransUnit +import io.tolgee.util.appendXmlOrText +import io.tolgee.util.attr +import io.tolgee.util.buildDom +import io.tolgee.util.element +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.InputStream + +class XliffFileWriter(private val xliffModel: XliffModel, private val enableXmlContent: Boolean) { + private lateinit var xliffElement: Element + + fun produceFiles(): InputStream { + return buildDom { + xliffElement = createBaseDocumentStructure() + + for (xliffFile in xliffModel.files) { + val file = createFileBody(xliffFile) + for (transUnit in xliffFile.transUnits) { + file.addToElement(transUnit) + } + } + }.write().toByteArray().inputStream() + } + + private fun Document.createBaseDocumentStructure(): Element { + return element("xliff") { + attr("version", "1.2") + attr("xmlns", "urn:oasis:names:tc:xliff:document:1.2") + } + } + + private fun createFileBody(file: XliffFile): Element { + return xliffElement.element("file") { + element("header") { + element("tool") { + attr("tool-id", "tolgee.io") + attr("tool-name", "Tolgee") + } + } + attr("original", file.original ?: "") + attr("datatype", file.datatype) + file.sourceLanguage?.let { attr("source-language", it) } + file.targetLanguage?.let { attr("target-language", it) } + return element("body") + } + } + + private fun Element.addToElement(transUnit: XliffTransUnit) { + element("trans-unit") { + attr("id", transUnit.id) + + element("source") { + attr("xml:space", "preserve") + appendXmlIfEnabledOrText(transUnit.source) + } + + if (transUnit.target != null) { + element("target") { + attr("xml:space", "preserve") + appendXmlIfEnabledOrText(transUnit.target) + } + } + + if (transUnit.note != null) { + element("note") { + attr("xml:space", "preserve") + appendXmlIfEnabledOrText(transUnit.note) + } + } + } + } + + private fun Element.appendXmlIfEnabledOrText(content: String?) { + if (!enableXmlContent) { + textContent = content + return + } + this.appendXmlOrText(content) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt index 170005c4e9..c249fb51b0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Project.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Project.kt @@ -1,6 +1,7 @@ package io.tolgee.model import io.tolgee.activity.annotation.ActivityLoggedProp +import io.tolgee.api.ISimpleProject import io.tolgee.exceptions.NotFoundException import io.tolgee.model.automations.Automation import io.tolgee.model.contentDelivery.ContentDeliveryConfig @@ -31,6 +32,7 @@ import jakarta.persistence.UniqueConstraint import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size +import org.hibernate.annotations.ColumnDefault import org.springframework.beans.factory.ObjectFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Configurable @@ -46,10 +48,10 @@ class Project( @field:NotBlank @field:Size(min = 3, max = 50) @ActivityLoggedProp - var name: String = "", + override var name: String = "", @field:Size(min = 3, max = 2000) @ActivityLoggedProp - var description: String? = null, + override var description: String? = null, @field:Size(max = 2000) @Column(columnDefinition = "text") @ActivityLoggedProp @@ -58,8 +60,8 @@ class Project( @ActivityLoggedProp @field:Size(min = 3, max = 60) @field:Pattern(regexp = "^[a-z0-9-]*[a-z]+[a-z0-9-]*$", message = "invalid_pattern") - var slug: String? = null, -) : AuditModel(), ModelWithAvatar, EntityWithId, SoftDeletable { + override var slug: String? = null, +) : AuditModel(), ModelWithAvatar, EntityWithId, SoftDeletable, ISimpleProject { @OrderBy("id") @OneToMany(fetch = FetchType.LAZY, mappedBy = "project") var languages: MutableSet = LinkedHashSet() @@ -112,6 +114,9 @@ class Project( @OneToMany(orphanRemoval = true, mappedBy = "project") var webhookConfigs: MutableList = mutableListOf() + @ColumnDefault("true") + override var icuPlaceholders: Boolean = true + override var deletedAt: Date? = null constructor(name: String, description: String? = null, slug: String?, organizationOwner: Organization) : diff --git a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt index ea47214002..b9d29e8b66 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt @@ -2,18 +2,22 @@ package io.tolgee.model.contentDelivery import io.hypersistence.utils.hibernate.type.json.JsonBinaryType import io.tolgee.dtos.IExportParams -import io.tolgee.dtos.request.export.ExportFormat +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat import io.tolgee.model.Project import io.tolgee.model.StandardAuditModel import io.tolgee.model.automations.AutomationAction import io.tolgee.model.enums.TranslationState import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.FetchType import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint +import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.Type import java.util.* @@ -44,6 +48,9 @@ class ContentDeliveryConfig( override var format: ExportFormat = ExportFormat.JSON override var structureDelimiter: Char? = '.' + @ColumnDefault("false") + override var supportArrays: Boolean = false + @Type(JsonBinaryType::class) @Column(columnDefinition = "jsonb") override var filterKeyId: List? = null @@ -65,4 +72,7 @@ class ContentDeliveryConfig( @Type(JsonBinaryType::class) @Column(columnDefinition = "jsonb") override var filterNamespace: List? = null + + @Enumerated(EnumType.STRING) + override var messageFormat: ExportMessageFormat? = null } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt index 1f1ce5a9ab..979fbb10e5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportFile.kt @@ -10,6 +10,7 @@ import jakarta.persistence.Entity import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.validation.constraints.Size +import org.hibernate.annotations.ColumnDefault @Entity class ImportFile( @@ -30,18 +31,28 @@ class ImportFile( var namespace: String? = null + @ColumnDefault("false") + var needsParamConversion = false + fun addIssue( type: FileIssueType, params: Map, - ) { - val issue = - ImportFileIssue(file = this, type = type).apply { - this.params = - params.map { - ImportFileIssueParam(this, it.key, it.value.shortenWithEllipsis()) - }.toMutableList() - } + ): ImportFileIssue { + val issue = prepareIssue(type, params) this.issues.add(issue) + return issue + } + + fun prepareIssue( + type: FileIssueType, + params: Map, + ): ImportFileIssue { + return ImportFileIssue(file = this, type = type).apply { + this.params = + params.map { + ImportFileIssueParam(this, it.key, it.value.shortenWithEllipsis()) + }.toMutableList() + } } fun addKeyIsNotStringIssue( @@ -105,4 +116,12 @@ class ImportFile( } return this } + + fun addIssues( + fileCollisions: MutableList>>, + ): List { + return fileCollisions.map { (issueType, params) -> + addIssue(issueType, params) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportKey.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportKey.kt index ee1f455649..0042259121 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportKey.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportKey.kt @@ -25,6 +25,8 @@ class ImportKey( @OneToOne(mappedBy = "importKey") override var keyMeta: KeyMeta? = null + var pluralArgName: String? = null + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettings.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettings.kt new file mode 100644 index 0000000000..e2dc9d6bee --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettings.kt @@ -0,0 +1,29 @@ +package io.tolgee.model.dataImport + +import io.tolgee.api.IImportSettings +import io.tolgee.model.AuditModel +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.IdClass +import jakarta.persistence.ManyToOne +import org.hibernate.annotations.ColumnDefault + +@Entity +@IdClass(ImportSettingsId::class) +class ImportSettings( + @Id + @ManyToOne + val project: Project, +) : AuditModel(), IImportSettings { + @ManyToOne + @Id + lateinit var userAccount: UserAccount + + @ColumnDefault("false") + override var overrideKeyDescriptions: Boolean = false + + @ColumnDefault("true") + override var convertPlaceholdersToIcu: Boolean = true +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettingsId.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettingsId.kt new file mode 100644 index 0000000000..66a78401a3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportSettingsId.kt @@ -0,0 +1,8 @@ +package io.tolgee.model.dataImport + +import java.io.Serializable + +data class ImportSettingsId( + val userAccount: Long? = null, + val project: Long? = null, +) : Serializable diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportTranslation.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportTranslation.kt index 58f95a288b..5ed94d8512 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportTranslation.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/ImportTranslation.kt @@ -1,12 +1,18 @@ package io.tolgee.model.dataImport +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType +import io.tolgee.formats.ImportMessageConvertorType import io.tolgee.model.StandardAuditModel import io.tolgee.model.translation.Translation import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.ManyToOne import jakarta.validation.constraints.NotNull import org.apache.commons.codec.digest.MurmurHash3 +import org.hibernate.annotations.ColumnDefault +import org.hibernate.annotations.Type import java.nio.ByteBuffer import java.util.* @@ -46,6 +52,27 @@ class ImportTranslation( resolvedHash = conflict?.text.computeMurmur() } + /** + * If user selects the same language for multiple files, there can be conflicts between translations + * of same language and key. This field is used to select which translation should be imported + */ + @ColumnDefault("true") + var isSelectedToImport: Boolean = true + + @ColumnDefault("false") + var isPlural = false + + @Column(columnDefinition = "jsonb") + @Type(JsonBinaryType::class) + var rawData: Any? = null + + /** + * This is the converted used or to be used to convert the message. + * When user enabled the conversion to TUICUP this field tells you what convertor to use + */ + @Enumerated(EnumType.STRING) + var convertor: ImportMessageConvertorType? = null + private fun String?.computeMurmur(): String? { if (this == null) { return "__null_value" diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/issueTypes/FileIssueType.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/issueTypes/FileIssueType.kt index 9a11a367a0..fe701eacd8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/issueTypes/FileIssueType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/issueTypes/FileIssueType.kt @@ -11,4 +11,6 @@ enum class FileIssueType { TARGET_NOT_PROVIDED, TRANSLATION_TOO_LONG, KEY_IS_BLANK, + TRANSLATION_DEFINED_IN_ANOTHER_FILE, + INVALID_CUSTOM_VALUES, } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/paramTypes/FileIssueParamType.kt b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/paramTypes/FileIssueParamType.kt index 76f5ed41fc..b66603f9a8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/paramTypes/FileIssueParamType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/dataImport/issues/paramTypes/FileIssueParamType.kt @@ -8,4 +8,5 @@ enum class FileIssueParamType { VALUE, LINE, FILE_NODE_ORIGINAL, + LANGUAGE_NAME, } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/announcement/Announcement.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/announcement/Announcement.kt index e07b4518b1..867d59a71f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/announcement/Announcement.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/announcement/Announcement.kt @@ -12,7 +12,8 @@ enum class Announcement( FEATURE_MT_FORMALITY(parseTime("2023-10-20 00:00 UTC")), FEATURE_CONTENT_DELIVERY_AND_WEBHOOKS(parseTime("2024-01-05 00:00 UTC")), NEW_PRICING(parseTime("2024-02-01 00:00 UTC"), AnnouncementTarget.SELF_HOSTED), - FEATURE_AI_CUSTOMIZATION(parseTime("2024-03-15 00:00 UTC"), AnnouncementTarget.CLOUD), + FEATURE_AI_CUSTOMIZATION(parseTime("2024-03-05 00:00 UTC"), AnnouncementTarget.CLOUD), + FEATURE_VISUAL_EDITOR(parseTime("2024-05-01 00:00 UTC")), ; companion object { diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt index 7f69704899..fd2236b77d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/Key.kt @@ -26,6 +26,7 @@ import jakarta.persistence.PreRemove import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size +import org.hibernate.annotations.ColumnDefault import org.springframework.beans.factory.ObjectFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Configurable @@ -61,6 +62,13 @@ class Key( @OneToMany(mappedBy = "key", orphanRemoval = true) var keyScreenshotReferences: MutableList = mutableListOf() + @ActivityLoggedProp + @ColumnDefault("false") + var isPlural: Boolean = false + + @ActivityLoggedProp + var pluralArgName: String? = null + constructor( name: String, project: Project, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyMeta.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyMeta.kt index fdd33d5b9d..4305e19e03 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyMeta.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyMeta.kt @@ -1,5 +1,6 @@ package io.tolgee.model.key +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType import io.tolgee.activity.annotation.ActivityEntityDescribingPaths import io.tolgee.activity.annotation.ActivityLoggedEntity import io.tolgee.activity.annotation.ActivityLoggedProp @@ -17,6 +18,7 @@ import jakarta.persistence.OrderBy import jakarta.persistence.PrePersist import jakarta.persistence.PreUpdate import jakarta.validation.constraints.Size +import org.hibernate.annotations.Type @Entity @EntityListeners(KeyMeta.Companion.KeyMetaListener::class) @@ -46,6 +48,11 @@ class KeyMeta( @Size(max = 2000) var description: String? = null + @ActivityLoggedProp + @Column(columnDefinition = "jsonb") + @Type(JsonBinaryType::class) + var custom: MutableMap? = null + fun addComment( author: UserAccount? = null, ft: KeyComment.() -> Unit, @@ -66,6 +73,18 @@ class KeyMeta( } } + fun setCustom( + key: String, + value: Any?, + ) { + val custom = + custom ?: mutableMapOf() + .also { + custom = it + } + custom[key] = value + } + companion object { class KeyMetaListener { @PrePersist diff --git a/backend/data/src/main/kotlin/io/tolgee/model/translation/Translation.kt b/backend/data/src/main/kotlin/io/tolgee/model/translation/Translation.kt index 08e8801b92..26649643bd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/translation/Translation.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/translation/Translation.kt @@ -147,6 +147,10 @@ class Translation( @PrePersist @PreUpdate fun preSave(translation: Translation) { + if (translation.text == "") { + translation.text = null + } + if (!translation.isEmpty() && translation.state == TranslationState.DISABLED) { throw BadRequestException(Message.CANNOT_MODIFY_DISABLED_TRANSLATION) } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ImportTranslationView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ImportTranslationView.kt index 0c6bab99e4..59adb544a0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ImportTranslationView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ImportTranslationView.kt @@ -5,6 +5,11 @@ interface ImportTranslationView { val text: String? val keyName: String val keyId: Long + val keyDescription: String? + + // there is some kind of Kotlin / Spring Issue when naming params with is* prefix + val plural: Boolean + val existingKeyPlural: Boolean? val conflictId: Long? val conflictText: String? val override: Boolean diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt index 8f087dfe9b..c94974291f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/KeyWithTranslationsView.kt @@ -9,6 +9,8 @@ import io.tolgee.model.key.Tag data class KeyWithTranslationsView( val keyId: Long, val keyName: String, + val keyIsPlural: Boolean, + val keyPluralArgName: String?, val keyNamespaceId: Long?, val keyNamespace: String?, val keyDescription: String?, @@ -29,6 +31,8 @@ data class KeyWithTranslationsView( KeyWithTranslationsView( keyId = data.removeFirst() as Long, keyName = data.removeFirst() as String, + keyIsPlural = data.removeFirst() as Boolean, + keyPluralArgName = data.removeFirst() as String?, keyNamespaceId = data.removeFirst() as Long?, keyNamespace = data.removeFirst() as String?, keyDescription = data.removeFirst() as String?, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt index e6e99b5544..eb950ad132 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectView.kt @@ -15,4 +15,5 @@ interface ProjectView { val organizationOwner: Organization val organizationRole: OrganizationRoleType? val directPermission: Permission? + var icuPlaceholders: Boolean } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt index 56c7a52e02..03c0d392e9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithLanguagesView.kt @@ -15,6 +15,7 @@ open class ProjectWithLanguagesView( override val organizationOwner: Organization, override val organizationRole: OrganizationRoleType?, override val directPermission: Permission?, + override var icuPlaceholders: Boolean, val permittedLanguageIds: List?, ) : ProjectView { companion object { @@ -33,6 +34,7 @@ open class ProjectWithLanguagesView( organizationRole = view.organizationRole, directPermission = view.directPermission, permittedLanguageIds = permittedLanguageIds, + icuPlaceholders = view.icuPlaceholders, ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt index d47f5c2ca8..ae612719bb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/ProjectWithStatsView.kt @@ -17,5 +17,6 @@ class ProjectWithStatsView( view.organizationOwner, view.organizationRole, view.directPermission, + view.icuPlaceholders, view.permittedLanguageIds, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt index c4511110f2..7016537f81 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt @@ -38,7 +38,7 @@ interface KeyRepository : JpaRepository { @Query( """ - select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description) + select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description, km.custom) from Key k left join k.keyMeta km left join k.namespace ns @@ -123,7 +123,7 @@ interface KeyRepository : JpaRepository { @Query( """ - select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description) + select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description, km.custom) from Key k left join k.keyMeta km left join k.namespace ns @@ -139,6 +139,21 @@ interface KeyRepository : JpaRepository { pageable: Pageable, ): Page + @Query( + """ + select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description, km.custom) + from Key k + left join k.keyMeta km + left join k.namespace ns + where k.project.id = :projectId + and k.id = :id + """, + ) + fun findView( + projectId: Long, + id: Long, + ): KeyView? + @Query( """ select k from Key k @@ -197,7 +212,7 @@ interface KeyRepository : JpaRepository { @Query( """ - select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description) + select new io.tolgee.dtos.queryResults.KeyView(k.id, k.name, ns.name, km.description, km.custom) from Key k left join k.keyMeta km left join k.namespace ns diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index d2246efe7e..b843ab089a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -16,7 +16,7 @@ interface ProjectRepository : JpaRepository { const val BASE_VIEW_QUERY = """select r.id as id, r.name as name, r.description as description, r.slug as slug, r.avatarHash as avatarHash, bl as baseLanguage, o as organizationOwner, - role.type as organizationRole, p as directPermission + role.type as organizationRole, p as directPermission, r.icuPlaceholders as icuPlaceholders from Project r left join r.baseLanguage bl left join Permission p on p.project = r and p.user.id = :userAccountId diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt index 8307a6815a..a9f0788efa 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TranslationRepository.kt @@ -72,6 +72,20 @@ interface TranslationRepository : JpaRepository { ) fun getAllByKeyIdIn(keyIds: Collection): Collection + @Query( + """from Translation t + join fetch t.key k + left join fetch k.keyMeta + left join fetch t.comments + where t.key.id in :keyIds + and (:excludeTranslationIds is null or t.id not in :excludeTranslationIds) + """, + ) + fun getAllByKeyIdInExcluding( + keyIds: Collection, + excludeTranslationIds: List? = null, + ): Collection + @Query( """select t.id from Translation t where t.key.id in (select k.id from t.key k where k.project.id = :projectId)""", @@ -100,12 +114,13 @@ interface TranslationRepository : JpaRepository { target.text is not null where baseTranslation.language.id = p.baseLanguage.id and cast(similarity(baseTranslation.text, :baseTranslationText) as float)> 0.5F and - (:key is null or key <> :key) + (:key is null or key <> :key) and target.key.isPlural = :isPlural order by similarity desc """, ) fun getTranslateMemorySuggestions( baseTranslationText: String, + isPlural: Boolean, key: Key? = null, targetLanguageId: Long, pageable: Pageable, diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportLanguageRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportLanguageRepository.kt index e63130eff8..bb0879d08f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportLanguageRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportLanguageRepository.kt @@ -25,7 +25,8 @@ interface ImportLanguageRepository : JpaRepository { count(it) as totalCount, sum(case when it.conflict is null then 0 else 1 end) as conflictCount, sum(case when (it.conflict is null or it.resolvedHash is null) then 0 else 1 end) as resolvedCount - from ImportLanguage il join il.file if left join il.existingLanguage el left join il.translations it + from ImportLanguage il join il.file if left join il.existingLanguage el left + join il.translations it on it.isSelectedToImport = true """ private const val VIEW_GROUP_BY = """ diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportTranslationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportTranslationRepository.kt index a166843ac1..c5b518347a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportTranslationRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/dataImport/ImportTranslationRepository.kt @@ -35,8 +35,20 @@ interface ImportTranslationRepository : JpaRepository { @Query( """ select it.id as id, it.text as text, ik.name as keyName, ik.id as keyId, - itc.id as conflictId, itc.text as conflictText, it.override as override, it.resolvedHash as resolvedHash - from ImportTranslation it left join it.conflict itc join it.key ik + itc.id as conflictId, itc.text as conflictText, it.override as override, it.resolvedHash as resolvedHash, + it.isPlural as plural, ek.isPlural as existingKeyPlural, + (case when is.overrideKeyDescriptions or (ekm.description is null or ekm.description = '') + then ikm.description else ikm.description end) as keyDescription, + ekm.description as existingKeyDescription + from ImportTranslation it + left join it.conflict itc + join it.key ik + left join Namespace en on ik.file.namespace = en.name and en.project = ik.file.import.project + left join Key ek on it.key.name = ek.name and ek.project = it.key.file.import.project + and (ek.namespace = en or (ek.namespace is null and en is null)) + left join ik.keyMeta ikm + left join ek.keyMeta ekm + left join ImportSettings is on is.project = ik.file.import.project where (itc.id is not null or :onlyConflicts = false) and ((itc.id is not null and it.resolvedHash is null) or :onlyUnresolved = false) and it.language.id = :languageId @@ -64,4 +76,24 @@ interface ImportTranslationRepository : JpaRepository { ) @Modifying fun deleteAllByImport(import: Import) + + fun findByIdAndLanguageId( + translationId: Long, + languageId: Long, + ): ImportTranslation? + + @Query( + """ + select distinct it from ImportTranslation it + join fetch it.key ik + left join fetch ik.keyMeta + left join fetch it.conflict ic + left join fetch ic.key ick + left join fetch ick.keyMeta + join fetch it.language il + join il.file if + where if.needsParamConversion = true + """, + ) + fun findTranslationsForPlaceholderConversion(importId: Long): List } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/LanguageService.kt b/backend/data/src/main/kotlin/io/tolgee/service/LanguageService.kt index cfe6d6908c..6c6f06ee00 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/LanguageService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/LanguageService.kt @@ -96,7 +96,7 @@ class LanguageService( projectId: Long, userId: Long, ): Set { - val all = getProjectLanguages(projectId) + val all = self.getProjectLanguages(projectId) val viewLanguageIds = permissionService.getProjectPermissionData( projectId, @@ -110,7 +110,11 @@ class LanguageService( all.filter { viewLanguageIds.contains(it.id) } } - return permitted.sortedBy { it.id }.take(2).toSet() + return permitted + .sortedBy { it.id } + // base first + .sortedBy { if (it.base) 0 else 1 } + .take(2).toSet() } @Transactional diff --git a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt index 63eee52812..aacceae2f8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/StartupImportService.kt @@ -6,7 +6,7 @@ import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.dtos.dataImport.ImportAddFilesParams import io.tolgee.dtos.dataImport.ImportFileDto import io.tolgee.dtos.request.LanguageRequest -import io.tolgee.dtos.request.project.CreateProjectDTO +import io.tolgee.dtos.request.project.CreateProjectRequest import io.tolgee.model.ApiKey import io.tolgee.model.Organization import io.tolgee.model.Project @@ -140,7 +140,7 @@ class StartupImportService( val project = projectService.createProject( - CreateProjectDTO( + CreateProjectRequest( name = projectName, languages = languages, organizationId = organization.id, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt index ba44c06f10..40446366a9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/CoreImportFilesProcessor.kt @@ -1,10 +1,13 @@ package io.tolgee.service.dataImport +import io.tolgee.api.IImportSettings import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.dataImport.ImportAddFilesParams import io.tolgee.dtos.dataImport.ImportFileDto import io.tolgee.exceptions.ErrorResponseBody import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportFileProcessorFactory import io.tolgee.model.dataImport.Import import io.tolgee.model.dataImport.ImportFile import io.tolgee.model.dataImport.ImportKey @@ -13,17 +16,24 @@ import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.service.LanguageService import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.ProcessorFactory import io.tolgee.util.Logging +import io.tolgee.util.filterFiles +import io.tolgee.util.getSafeNamespace import org.springframework.context.ApplicationContext class CoreImportFilesProcessor( val applicationContext: ApplicationContext, val import: Import, val params: ImportAddFilesParams = ImportAddFilesParams(), + val importSettings: IImportSettings, + val projectIcuPlaceholdersEnabled: Boolean = true, ) : Logging { private val importService: ImportService by lazy { applicationContext.getBean(ImportService::class.java) } - private val processorFactory: ProcessorFactory by lazy { applicationContext.getBean(ProcessorFactory::class.java) } + private val importFileProcessorFactory: ImportFileProcessorFactory by lazy { + applicationContext.getBean( + ImportFileProcessorFactory::class.java, + ) + } private val tolgeeProperties: TolgeeProperties by lazy { applicationContext.getBean(TolgeeProperties::class.java) } private val languageService: LanguageService by lazy { applicationContext.getBean(LanguageService::class.java) } @@ -68,20 +78,31 @@ class CoreImportFilesProcessor( fileEntity = savedFileEntity, maxTranslationTextLength = tolgeeProperties.maxTranslationTextLength, params = params, + importSettings, + projectIcuPlaceholdersEnabled, + applicationContext, ) - val processor = processorFactory.getProcessor(file, fileProcessorContext) + val processor = importFileProcessorFactory.getProcessor(file, fileProcessorContext) processor.process() - processor.context.processResult() + processor.processResult() + savedFileEntity.updateFileEntity(fileProcessorContext) + } + + private fun ImportFile.updateFileEntity(fileProcessorContext: FileProcessorContext) { + if (fileProcessorContext.needsParamConversion) { + this.needsParamConversion = fileProcessorContext.needsParamConversion + importService.saveFile(this) + } } private fun processArchive( archive: ImportFileDto, errors: MutableList, ): MutableList { - val processor = processorFactory.getArchiveProcessor(archive) + val processor = importFileProcessorFactory.getArchiveProcessor(archive) val files = processor.process(archive) - - errors.addAll(processFiles(files)) + val filtered = filterFiles(files.map { it.name to it }) + errors.addAll(processFiles(filtered)) return errors } @@ -100,18 +121,23 @@ class CoreImportFilesProcessor( return importService.saveFile(entity) } - private fun FileProcessorContext.processResult() { - this.fileEntity.preselectNamespace() - this.processLanguages() - this.processTranslations() - importService.saveAllFileIssues(this.fileEntity.issues) - importService.saveAllFileIssueParams(this.fileEntity.issues.flatMap { it.params ?: emptyList() }) + private fun ImportFileProcessor.processResult() { + context.preselectNamespace() + context.processLanguages() + context.processTranslations() + importService.saveAllFileIssues(this.context.fileEntity.issues) } - private fun ImportFile.preselectNamespace() { - val namespace = """^[\/]?([^/\\]+)[/\\].*""".toRegex().matchEntire(this.name!!)?.groups?.get(1)?.value + private fun FileProcessorContext.preselectNamespace() { + if (this.namespace != null) { + // namespace was selected by processor + this.fileEntity.namespace = getSafeNamespace(this.namespace) + return + } + // select namespace from file name + val namespace = """^[\/]?([^/\\]+)[/\\].*""".toRegex().matchEntire(this.fileEntity.name!!)?.groups?.get(1)?.value if (!namespace.isNullOrBlank()) { - this.namespace = namespace + this.fileEntity.namespace = namespace } } @@ -119,7 +145,7 @@ class CoreImportFilesProcessor( this.languages.forEach { entry -> val languageEntity = entry.value importDataManager.storedLanguages.add(languageEntity) - val existingLanguageDto = importDataManager.findMatchingExistingLanguage(languageEntity) + val existingLanguageDto = importDataManager.findMatchingExistingLanguage(languageEntity.name) languageEntity.existingLanguage = existingLanguageDto?.id?.let { languageService.getEntity(it) } importService.saveLanguages(this.languages.values) importDataManager.populateStoredTranslations(entry.value) @@ -134,8 +160,6 @@ class CoreImportFilesProcessor( return importDataManager.storedKeys.computeIfAbsent(this.fileEntity to name) { this.keys.computeIfAbsent(name) { ImportKey(name = name, this.fileEntity) - }.also { - importService.saveKey(it) } } } @@ -144,24 +168,58 @@ class CoreImportFilesProcessor( this.translations.forEach { entry -> val keyEntity = getOrCreateKey(entry.key) entry.value.forEach translationForeach@{ newTranslation -> - val storedTranslations = importDataManager.getStoredTranslations(keyEntity, newTranslation.language) - newTranslation.key = keyEntity - if (storedTranslations.size > 0) { - storedTranslations.forEach { collidingTranslations -> - fileEntity.addIssue( - FileIssueType.MULTIPLE_VALUES_FOR_KEY_AND_LANGUAGE, - mapOf( - FileIssueParamType.KEY_ID to collidingTranslations.key.id.toString(), - FileIssueParamType.LANGUAGE_ID to collidingTranslations.language.id.toString(), - ), - ) - } - return@translationForeach - } - this@CoreImportFilesProcessor.addToStoredTranslations(newTranslation) + processTranslation(newTranslation, keyEntity) } } importDataManager.saveAllStoredKeys() importDataManager.saveAllStoredTranslations() } + + private fun FileProcessorContext.processTranslation( + newTranslation: ImportTranslation, + keyEntity: ImportKey, + ) { + newTranslation.key = keyEntity + val (isCollision, fileCollisions) = checkForInFileCollisions(newTranslation) + if (isCollision) { + fileEntity.addIssues(fileCollisions) + return + } + val otherFilesCollisions = + importDataManager.checkForOtherFilesCollisions(newTranslation) + if (otherFilesCollisions.isNotEmpty()) { + fileEntity.addIssues(otherFilesCollisions) + newTranslation.isSelectedToImport = false + } + this@CoreImportFilesProcessor.addToStoredTranslations(newTranslation) + } + + private fun checkForInFileCollisions( + newTranslation: ImportTranslation, + ): Pair>>> { + var isCollision = false + val issues = + mutableListOf>>() + val storedTranslations = + importDataManager + .getStoredTranslations(newTranslation.key, newTranslation.language) + if (storedTranslations.isNotEmpty()) { + isCollision = true + storedTranslations.forEach { collision -> + if (newTranslation.text == collision.text) { + return@forEach + } + issues.add( + FileIssueType.MULTIPLE_VALUES_FOR_KEY_AND_LANGUAGE to + mapOf( + FileIssueParamType.KEY_ID to collision.key.id.toString(), + FileIssueParamType.LANGUAGE_ID to collision.language.id.toString(), + FileIssueParamType.KEY_NAME to collision.key.name, + FileIssueParamType.LANGUAGE_NAME to collision.language.name, + ), + ) + } + } + return isCollision to issues + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportDataManager.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportDataManager.kt index c1f6b4bdfb..dfcc261c29 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportDataManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportDataManager.kt @@ -1,10 +1,17 @@ package io.tolgee.service.dataImport +import io.tolgee.api.IImportSettings import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.formats.CollisionHandler +import io.tolgee.formats.isSamePossiblePlural +import io.tolgee.model.Language import io.tolgee.model.dataImport.Import import io.tolgee.model.dataImport.ImportKey import io.tolgee.model.dataImport.ImportLanguage import io.tolgee.model.dataImport.ImportTranslation +import io.tolgee.model.dataImport.issues.ImportFileIssue +import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType +import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.model.key.Key import io.tolgee.model.key.KeyMeta import io.tolgee.model.translation.Translation @@ -14,6 +21,7 @@ import io.tolgee.service.key.KeyService import io.tolgee.service.key.NamespaceService import io.tolgee.service.translation.TranslationService import io.tolgee.util.Logging +import io.tolgee.util.getSafeNamespace import org.springframework.context.ApplicationContext class ImportDataManager( @@ -28,6 +36,10 @@ class ImportDataManager( private val languageService: LanguageService by lazy { applicationContext.getBean(LanguageService::class.java) } + private val collisionHandlers by lazy { + applicationContext.getBeansOfType(CollisionHandler::class.java).values + } + private val keyMetaService: KeyMetaService by lazy { applicationContext.getBean(KeyMetaService::class.java) } @@ -45,6 +57,8 @@ class ImportDataManager( val storedTranslations = mutableMapOf>>() + val translationsToUpdateDueToCollisions = mutableListOf() + /** * LanguageId to (Map of Pair(Namespace,KeyName) to Translation) */ @@ -85,7 +99,7 @@ class ImportDataManager( /** * Returns list of translations provided for a language and a key. - * It returns collection since translations could collide, when a user uploads multiple files with different values + * It returns collection since translations could collide, when a user uploads a file with different values * for a key */ fun getStoredTranslations( @@ -105,6 +119,19 @@ class ImportDataManager( return this.populateStoredTranslations(language).flatMap { it.value } } + private fun getStoredTranslations( + keyName: String, + keyNamespace: String?, + otherLanguages: List, + ): List { + val safeNamespace = getSafeNamespace(keyNamespace) + return otherLanguages.flatMap { + getStoredTranslations(it).filter { translation -> + translation.key.name == keyName && translation.key.file.namespace == safeNamespace + } + } + } + fun populateStoredTranslations(language: ImportLanguage): MutableMap> { var languageData = this.storedTranslations[language] if (languageData != null) { @@ -116,15 +143,25 @@ class ImportDataManager( val translations = importService.findTranslations(language.id) translations.forEach { importTranslation -> val keyTranslations = - languageData[importTranslation.key] ?: let { - languageData[importTranslation.key] = mutableListOf() - languageData[importTranslation.key]!! - } + languageData.computeIfAbsent(importTranslation.key) { mutableListOf() } keyTranslations.add(importTranslation) } return languageData } + private fun populateStoredTranslationsToConvertPlaceholders() { + val translations = importService.findTranslationsForPlaceholderConversion(import.id) + translations.forEach { + getOrInitLanguageDataItem(it.language)[it.key] = mutableListOf(it) + } + } + + private fun getOrInitLanguageDataItem( + language: ImportLanguage, + ): MutableMap> { + return this.storedTranslations.computeIfAbsent(language) { mutableMapOf() } + } + /** * @param removeEqual Whether translations with equal texts should be removed */ @@ -139,7 +176,7 @@ class ImportDataManager( ?.let { it[importedTranslation.language.file.namespace to importedTranslation.key.name] } if (existingTranslation != null) { // remove if text is the same - if (existingTranslation.text == importedTranslation.text) { + if (existingTranslation.text isSamePossiblePlural importedTranslation.text) { toRemove.add(importedTranslation) } else { importedTranslation.conflict = existingTranslation @@ -188,37 +225,182 @@ class ImportDataManager( this.populateStoredTranslations(importLanguage) this.resetConflicts(importLanguage) this.handleConflicts(false) - if (isExistingLanguageUsed(importLanguage.existingLanguage?.id, importLanguage)) { - importLanguage.existingLanguage = null - } this.importService.saveLanguages(listOf(importLanguage)) this.saveAllStoredTranslations() } - private fun isExistingLanguageUsed( - existingId: Long?, - imported: ImportLanguage, - ): Boolean { - existingId ?: return false - return this.storedLanguages.any { storedLang -> - imported != storedLang && // ignore when is assigned to self - storedLang.existingLanguage?.id == existingId && - storedLang.file.namespace == imported.file.namespace - } - } - - fun findMatchingExistingLanguage(importLanguage: ImportLanguage): LanguageDto? { + fun findMatchingExistingLanguage(importLanguageName: String): LanguageDto? { val possibleTag = """(?:.*?)/?([a-zA-Z0-9-_]+)[^/]*?""".toRegex() - .matchEntire(importLanguage.name)?.groups?.get(1)?.value + .matchEntire(importLanguageName)?.groups?.get(1)?.value ?: return null val candidate = languageService.findByTag(possibleTag, import.project.id) - if (!isExistingLanguageUsed(candidate?.id, importLanguage)) { - return candidate + return candidate + } + + fun resetCollisionsBetweenFiles( + editedLanguage: ImportLanguage, + oldExistingLanguage: Language?, + ) { + val affectedLanguages = + storedLanguages.filter { + ( + (editedLanguage.existingLanguage == it.existingLanguage && it.existingLanguage != null) || + (oldExistingLanguage == it.existingLanguage && it.existingLanguage != null) + ) && + it != editedLanguage + } + .sortedBy { it.id } + listOf(editedLanguage) + val affectedFiles = affectedLanguages.map { it.file } + resetBetweenFileCollisionIssuesForFiles(affectedFiles.map { it.id }, affectedLanguages.map { it.id }) + val handledLanguages = mutableListOf() + val issuesToSave = mutableListOf() + affectedLanguages.forEach { language -> + getStoredTranslations(language).forEach { + if (!it.isSelectedToImport) { + translationsToUpdateDueToCollisions.add(it) + } + it.isSelectedToImport = true + } + + if (handledLanguages.isEmpty()) { + handledLanguages.add(language) + resetIsSelected(language) + return@forEach + } + + getStoredTranslations(language).forEach { translation -> + val withSameExistingLanguage = + handledLanguages.filter { it.existingLanguage == language.existingLanguage && it.existingLanguage != null } + val fileCollisions = checkForOtherFilesCollisions(translation, withSameExistingLanguage) + translationsToUpdateDueToCollisions.add(translation) + if (fileCollisions.isNotEmpty()) { + translation.isSelectedToImport = false + fileCollisions.forEach { (type, params) -> + val issue = language.file.prepareIssue(type, params) + issuesToSave.add(issue) + } + } + } + } + + updateTranslationsDueToCollisions() + importService.saveAllFileIssues(issuesToSave) + } + + private fun updateTranslationsDueToCollisions() { + importService.updateIsSelectedForTranslations(translationsToUpdateDueToCollisions) + translationsToUpdateDueToCollisions.clear() + } + + private fun resetIsSelected(language: ImportLanguage) { + getStoredTranslations(language).forEach { it.isSelectedToImport = true } + } + + private fun getLanguagesWithSameExisting(importLanguage: ImportLanguage): List { + if (importLanguage.existingLanguage == null) { + return emptyList() + } + return storedLanguages.filter { it.existingLanguage == importLanguage.existingLanguage } + } + + private fun checkForOtherFilesCollisions( + newTranslation: ImportTranslation, + otherLanguages: List, + ): MutableList>> { + val issues = + mutableListOf>>() + val storedTranslations = + getStoredTranslations( + newTranslation.key.name, + newTranslation.key.file.namespace, + otherLanguages, + ) + + storedTranslations.firstOrNull { + it.isSelectedToImport && it.text != newTranslation.text + }?.let { collision -> + val handled = tryHandleUsingCollisionHandlers(listOf(newTranslation) + storedTranslations) + if (handled) { + return issues + } + issues.add( + FileIssueType.TRANSLATION_DEFINED_IN_ANOTHER_FILE to + mapOf( + FileIssueParamType.KEY_ID to collision.key.id.toString(), + FileIssueParamType.LANGUAGE_ID to collision.language.id.toString(), + FileIssueParamType.KEY_NAME to collision.key.name, + FileIssueParamType.LANGUAGE_NAME to collision.language.name, + ), + ) + } + return issues + } + + private fun tryHandleUsingCollisionHandlers(importTranslations: List): Boolean { + val toIgnore = + collisionHandlers.firstNotNullOfOrNull { + it.handle(importTranslations) + } ?: return false + + toIgnore.forEach { + translationsToUpdateDueToCollisions.add(it) + it.isSelectedToImport = false } - return null + return true + } + + private fun resetBetweenFileCollisionIssuesForFiles( + fileIds: List, + languageIds: List, + ) { + importService.deleteAllBetweenFileCollisionsForFiles(fileIds, languageIds) + } + + fun checkForOtherFilesCollisions( + newTranslation: ImportTranslation, + ): MutableList>> { + return checkForOtherFilesCollisions(newTranslation, getLanguagesWithSameExisting(newTranslation.language)) + } + + fun applySettings( + oldSettings: IImportSettings, + newSettings: IImportSettings, + ) { + if (oldSettings.convertPlaceholdersToIcu != newSettings.convertPlaceholdersToIcu) { + applyConvertPlaceholdersChange(newSettings.convertPlaceholdersToIcu) + } + } + + private fun applyConvertPlaceholdersChange(convertPlaceholdersToIcu: Boolean) { + this.populateStoredTranslationsToConvertPlaceholders() + val toSave = mutableListOf() + storedTranslations.forEach { (language, keyTranslationsMap) -> + keyTranslationsMap.forEach { (key, translations) -> + translations.forEach { + val convertor = it.convertor?.importMessageConvertor + if (convertor != null) { + val converted = + convertor.convert( + rawData = it.rawData, + languageTag = language.name, + convertPlaceholders = convertPlaceholdersToIcu, + isProjectIcuEnabled = import.project.icuPlaceholders, + ) + it.isPlural = converted.isPlural + it.text = converted.message + toSave.add(it) + } + } + } + } + toSave.map { it.language }.toSet().forEach { + resetConflicts(it) + handleConflicts(false) + } + importService.saveTranslations(toSave) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt index 06592a9e25..ee35c6a2a2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportService.kt @@ -1,6 +1,7 @@ package io.tolgee.service.dataImport import io.sentry.Sentry +import io.tolgee.api.IImportSettings import io.tolgee.component.CurrentDateProvider import io.tolgee.component.fileStorage.FileStorage import io.tolgee.component.reporting.BusinessEventPublisher @@ -41,6 +42,7 @@ import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -66,6 +68,9 @@ class ImportService( private val self: ImportService, private val fileStorage: FileStorage, private val tolgeeProperties: TolgeeProperties, + private val jdbcTemplate: JdbcTemplate, + @Lazy + private val importSettingsService: ImportSettingsService, ) { @Transactional fun addFiles( @@ -95,6 +100,8 @@ class ImportService( applicationContext = applicationContext, import = import, params = params, + projectIcuPlaceholdersEnabled = project.icuPlaceholders, + importSettings = importSettingsService.get(userAccount, project.id), ) val errors = fileProcessor.processFiles(files) @@ -121,7 +128,8 @@ class ImportService( reportStatus: (ImportApplicationStatus) -> Unit = {}, ) { Sentry.addBreadcrumb("Import ID: ${import.id}") - StoredDataImporter(applicationContext, import, forceMode, reportStatus).doImport() + val settings = importSettingsService.get(import.author, import.project.id) + StoredDataImporter(applicationContext, import, forceMode, reportStatus, settings).doImport() deleteImport(import) businessEventPublisher.publish( OnBusinessEventToCaptureEvent( @@ -143,18 +151,11 @@ class ImportService( val import = importLanguage.file.import Sentry.addBreadcrumb("Import ID: ${import.id}") val dataManager = ImportDataManager(applicationContext, import) - existingLanguage?.let { - val langAlreadySelectedInTheSameNS = - dataManager.storedLanguages.any { - it.existingLanguage?.id == existingLanguage.id && it.file.namespace == importLanguage.file.namespace - } - if (langAlreadySelectedInTheSameNS) { - throw BadRequestException(Message.LANGUAGE_ALREADY_SELECTED) - } - } + val oldExistingLanguage = importLanguage.existingLanguage importLanguage.existingLanguage = existingLanguage importLanguageRepository.save(importLanguage) dataManager.resetLanguage(importLanguage) + dataManager.resetCollisionsBetweenFiles(importLanguage, oldExistingLanguage) } @Transactional @@ -169,6 +170,7 @@ class ImportService( importFileRepository.save(file) file.languages.forEach { dataManager.resetLanguage(it) + dataManager.resetCollisionsBetweenFiles(it, null) } } @@ -336,17 +338,31 @@ class ImportService( this.importLanguageRepository.delete(language) if (this.findLanguages(import = language.file.import).isEmpty()) { deleteImport(import) + return } + entityManager.clear() + entityManager.refresh(entityManager.merge(import)) + val dataManager = ImportDataManager(applicationContext, import) + dataManager.resetCollisionsBetweenFiles(language, null) } fun findTranslation(translationId: Long): ImportTranslation? { return importTranslationRepository.findById(translationId).orElse(null) } + fun findTranslation( + translationId: Long, + languageId: Long, + ): ImportTranslation? { + return importTranslationRepository.findByIdAndLanguageId(translationId, languageId) + } + fun resolveTranslationConflict( - translation: ImportTranslation, + translationId: Long, + languageId: Long, override: Boolean, ) { + val translation = findTranslation(translationId, languageId) ?: throw NotFoundException() translation.override = override translation.resolve() importTranslationRepository.save(translation) @@ -381,6 +397,7 @@ class ImportService( fun saveAllFileIssues(issues: Iterable) { this.importFileIssueRepository.saveAll(issues) + this.saveAllFileIssueParams(issues.flatMap { it.params }) } fun getAllByProject(projectId: Long) = this.importRepository.findAllByProjectId(projectId) @@ -415,4 +432,67 @@ class ImportService( } private fun getFileStorageImportRoot(importId: Long) = "importFiles/$importId" + + fun deleteAllBetweenFileCollisionsForFiles( + ids: List, + importLanguageIds: List, + ) { + val languageIdStrings = importLanguageIds.map { it.toString() } + entityManager.createNativeQuery( + """ + with deleted as ( + delete from import_file_issue_param + where issue_id in ( + select import_file_issue.id from import_file_issue + join import_file_issue_param ifip on + import_file_issue.id = ifip.issue_id + and ifip.type = 2 + and ifip.value in :importLanguageIds + where file_id in :ids and import_file_issue.type = 10 + ) returning issue_id + ) + delete from import_file_issue + where id in (select issue_id from deleted) + """, + ) + .setParameter("ids", ids) + .setParameter("importLanguageIds", languageIdStrings) + .executeUpdate() + } + + fun updateIsSelectedForTranslations(translations: List) { + jdbcTemplate.batchUpdate( + "update import_translation set is_selected_to_import = ? where id = ?", + translations, + 100, + ) { ps, entity -> + ps.setBoolean(1, entity.isSelectedToImport) + ps.setLong(2, entity.id) + } + entityManager.clear() + } + + fun applySettings( + userAccount: UserAccount, + projectId: Long, + oldSettings: IImportSettings, + newSettings: IImportSettings, + ) { + find(projectId, userAccount.id)?.let { + applySettings(it, oldSettings, newSettings) + save(it) + } + } + + fun applySettings( + import: Import, + oldSettings: IImportSettings, + newSettings: IImportSettings, + ) { + ImportDataManager(applicationContext, import).applySettings(oldSettings, newSettings) + } + + fun findTranslationsForPlaceholderConversion(importId: Long): List { + return importTranslationRepository.findTranslationsForPlaceholderConversion(importId) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportSettingsService.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportSettingsService.kt new file mode 100644 index 0000000000..7a83d75821 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/ImportSettingsService.kt @@ -0,0 +1,60 @@ +package io.tolgee.service.dataImport + +import io.tolgee.api.IImportSettings +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.dataImport.ImportSettings +import io.tolgee.model.dataImport.ImportSettingsId +import io.tolgee.service.project.ProjectService +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ImportSettingsService( + private val entityManager: EntityManager, + private val importService: ImportService, + private val projectService: ProjectService, +) { + @Transactional + fun store( + userAccount: UserAccount, + projectId: Long, + settings: IImportSettings, + ): ImportSettings { + val existing = getOrCreateSettings(userAccount, projectId) + val oldSettings = existing.clone() + existing.assignFrom(settings) + entityManager.persist(existing) + importService.applySettings(userAccount, projectId, oldSettings, settings) + return existing + } + + @Transactional + fun get( + userAccount: UserAccount, + projectId: Long, + ): IImportSettings { + val icuPlaceholders = projectService.get(projectId).icuPlaceholders + return getOrCreateSettings(userAccount, projectId).also { + if (!icuPlaceholders) { + it.convertPlaceholdersToIcu = false + } + } + } + + private fun getOrCreateSettings( + userAccount: UserAccount, + projectId: Long, + ): ImportSettings { + return ( + entityManager + .find(ImportSettings::class.java, ImportSettingsId(userAccount.id, projectId)) + ?: ImportSettings( + entityManager.getReference(Project::class.java, projectId), + ).apply { + this.userAccount = userAccount + } + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/PluralizationHandler.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/PluralizationHandler.kt new file mode 100644 index 0000000000..c584b9aa15 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/PluralizationHandler.kt @@ -0,0 +1,84 @@ +package io.tolgee.service.dataImport + +import io.tolgee.formats.convertToIcuPlurals +import io.tolgee.model.dataImport.ImportTranslation +import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation +import io.tolgee.service.translation.TranslationService + +class PluralizationHandler( + private val importDataManager: ImportDataManager, + private val storedDataImporter: StoredDataImporter, + private val translationService: TranslationService, +) { + /** + * Map (keyId -> pluralArgName) + */ + private val existingKeysToMigrate = mutableMapOf() + private val ignoreTranslationsForMigration: MutableList = mutableListOf() + val keysToSave = mutableListOf() + + fun handlePluralization() { + val byKey = + storedDataImporter.translationsToSave + .groupBy { it.second.key.namespace?.name to it.second.key.name } + byKey.forEach { + handleKeyPluralization(it) + } + + migrateExistingKeys() + } + + private fun migrateExistingKeys() { + if (existingKeysToMigrate.isEmpty()) { + return + } + + translationService.onKeyIsPluralChanged(existingKeysToMigrate, true, ignoreTranslationsForMigration) + } + + private fun handleKeyPluralization( + data: Map.Entry, List>>, + ) { + // if any translation is plural, we are migrating key to plural + // key which already exists in the real data (not just in the import) is plural + val existingKey = importDataManager.existingKeys[data.key] + val exitingKeyIsPlural = existingKey?.isPlural ?: false + + if (exitingKeyIsPlural) { + migrateNewTranslationsToPlurals(data.value, existingKey?.pluralArgName) + return + } + + val anyNewTranslationIsPlural = data.value.any { (translation) -> translation.isPlural } + if (!anyNewTranslationIsPlural) { + return + } + + val existingOrNewKey = data.value.first().second.key + existingOrNewKey.isPlural = true + // now we have to migrate the new translations + val pluralArgName = migrateNewTranslationsToPlurals(data.value, null) + existingOrNewKey.pluralArgName = pluralArgName + keysToSave.add(existingOrNewKey) + + // if the key was already existing, we need to migrate its existing translations + if (existingKey != null) { + existingKeysToMigrate[existingKey.id] = pluralArgName + ignoreTranslationsForMigration.addAll(data.value.map { it.second.id }) + } + } + + private fun migrateNewTranslationsToPlurals( + translationPairs: List>, + pluralArgName: String?, + ): String { + val keyPluralArgName = pluralArgName ?: translationPairs.firstOrNull()?.first?.key?.pluralArgName + val map = translationPairs.associateWith { it.second.text } + val conversionResult = map.convertToIcuPlurals(keyPluralArgName) + conversionResult.convertedStrings.forEach { + it.key.second.text = it.value + } + return conversionResult.argName + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt index 48fe3c79e6..f7fd99e292 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/StoredDataImporter.kt @@ -1,5 +1,6 @@ package io.tolgee.service.dataImport +import io.tolgee.api.IImportSettings import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.ImportConflictNotResolvedException import io.tolgee.model.dataImport.Import @@ -25,6 +26,7 @@ class StoredDataImporter( private val import: Import, private val forceMode: ForceMode = ForceMode.NO_FORCE, private val reportStatus: (ImportApplicationStatus) -> Unit = {}, + private val importSettings: IImportSettings, ) { private val importDataManager = ImportDataManager(applicationContext, import) private val keyService = applicationContext.getBean(KeyService::class.java) @@ -34,7 +36,7 @@ class StoredDataImporter( private val securityService = applicationContext.getBean(SecurityService::class.java) - private val translationsToSave = mutableListOf() + val translationsToSave = mutableListOf>() /** * We need to persist data after everything is checked for resolved conflicts since @@ -92,6 +94,8 @@ class StoredDataImporter( val keyEntitiesToSave = saveKeys() + handlePluralization() + saveMetaData(keyEntitiesToSave) reportStatus(ImportApplicationStatus.STORING_TRANSLATIONS) @@ -107,7 +111,13 @@ class StoredDataImporter( entityManager.flushAndClear() } - private fun saveMetaData(keyEntitiesToSave: MutableCollection) { + private fun handlePluralization() { + val handler = PluralizationHandler(importDataManager, this, translationService) + handler.handlePluralization() + saveKeys(handler.keysToSave) + } + + private fun saveMetaData(keyEntitiesToSave: Collection) { keyEntitiesToSave.flatMap { it.keyMeta?.comments ?: emptyList() }.also { keyMetaService.saveAllComments(it) } @@ -118,13 +128,16 @@ class StoredDataImporter( private fun saveTranslations() { checkTranslationPermissions() - translationService.saveAll(translationsToSave) + translationService.saveAll(translationsToSave.map { it.second }) } - private fun saveKeys(): MutableCollection { - val keyEntitiesToSave = keysToSave.values - keyService.saveAll(keyEntitiesToSave) - return keyEntitiesToSave + private fun saveKeys(): Collection { + return saveKeys(keysToSave.values) + } + + private fun saveKeys(keys: Collection): Collection { + keyService.saveAll(keys) + return keys } private fun addKeysAndCheckPermissions() { @@ -133,7 +146,7 @@ class StoredDataImporter( } private fun checkTranslationPermissions() { - val langs = translationsToSave.map { it.language }.toSet().map { it.id } + val langs = translationsToSave.map { it.second.language }.toSet().map { it.id } securityService.checkLanguageTranslatePermission(import.project.id, langs) } @@ -154,7 +167,7 @@ class StoredDataImporter( // persist is cascaded on key, so it should be fine val keyMeta = importDataManager.existingMetas[fileNamePair.first.namespace to importKey.name]?.also { - keyMetaService.import(it, importedKeyMeta) + keyMetaService.import(it, importedKeyMeta, importSettings.overrideKeyDescriptions) } ?: importedKeyMeta // also set key and remove import key keyMeta.also { @@ -181,6 +194,9 @@ class StoredDataImporter( } private fun ImportTranslation.doImport() { + if (!this.isSelectedToImport) { + return + } this.checkConflictResolved() if (this.conflict == null || (this.override && this.resolved) || forceMode == ForceMode.OVERRIDE) { val language = @@ -196,7 +212,7 @@ class StoredDataImporter( } translation.text = this@doImport.text translation.resetFlags() - translationsToSave.add(translation) + translationsToSave.add(this to translation) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/FileProcessorContext.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/FileProcessorContext.kt index 9e863ea58d..194db56e6b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/FileProcessorContext.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/FileProcessorContext.kt @@ -1,7 +1,12 @@ package io.tolgee.service.dataImport.processors +import io.tolgee.api.IImportSettings +import io.tolgee.component.KeyCustomValuesValidator import io.tolgee.dtos.dataImport.ImportAddFilesParams import io.tolgee.dtos.dataImport.ImportFileDto +import io.tolgee.formats.ImportMessageConvertorType +import io.tolgee.formats.forceEscapePluralForms +import io.tolgee.formats.getPluralForms import io.tolgee.model.dataImport.ImportFile import io.tolgee.model.dataImport.ImportKey import io.tolgee.model.dataImport.ImportLanguage @@ -9,35 +14,113 @@ import io.tolgee.model.dataImport.ImportTranslation import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.model.key.KeyMeta +import org.springframework.context.ApplicationContext data class FileProcessorContext( - val file: ImportFileDto, + var file: ImportFileDto, val fileEntity: ImportFile, val maxTranslationTextLength: Long = 200L, val params: ImportAddFilesParams = ImportAddFilesParams(), + val importSettings: IImportSettings = + object : IImportSettings { + override var overrideKeyDescriptions: Boolean = false + override var convertPlaceholdersToIcu: Boolean = true + }, + val projectIcuPlaceholdersEnabled: Boolean = true, + val applicationContext: ApplicationContext, ) { var languages: MutableMap = mutableMapOf() - var translations: MutableMap> = mutableMapOf() + private var _translations: MutableMap> = mutableMapOf() + val translations: Map> get() = _translations val keys: MutableMap = mutableMapOf() - + var namespace: String? = null lateinit var languageNameGuesses: List + var needsParamConversion = false + /** + * @param forceIsPlural when is set to true, it will force the translation to be plurar, when set to false, + * it will force the translation not to be plural + */ fun addTranslation( keyName: String, languageName: String, value: Any?, idx: Int = 0, + forceIsPlural: Boolean? = null, + replaceNonPlurals: Boolean = false, + rawData: Any? = null, + convertedBy: ImportMessageConvertorType? = null, ) { val stringValue = value as? String + if (!validateAndSaveIssues(keyName, idx, value, stringValue)) return + + val language = getOrCreateLanguage(languageName) + + if (_translations[keyName] == null) { + _translations[keyName] = mutableListOf() + } + + val pluralForms = getPluralForms(stringValue) + val isPlural = forceIsPlural ?: (pluralForms != null) + + if (pluralForms?.argName != null) { + getOrCreateKey(keyName).pluralArgName = pluralForms.argName + } + + if (convertedBy != null) { + needsParamConversion = true + } + + if (value != null) { + val entity = + ImportTranslation(pluralForms?.icuString ?: stringValue, language).also { + it.isPlural = isPlural + it.rawData = rawData + it.convertor = convertedBy + } + if (isPlural && replaceNonPlurals) { + _translations[keyName]!!.removeIf { it.language == language && !it.isPlural } + } + _translations[keyName]!!.add(entity) + return + } + + createKey(keyName) + } + + /** + * This method currently supports adding translations in ICU Message Format + * Later, we will add support for other message formats + */ + fun addGenericFormatTranslation( + key: String, + languageName: String, + text: String?, + index: Int = 0, + ) { + if (!projectIcuPlaceholdersEnabled) { + val escapedPlural = text?.forceEscapePluralForms() + val escapedText = escapedPlural ?: text + return addTranslation(key, languageName, escapedText, index, forceIsPlural = escapedPlural != null) + } + return addTranslation(keyName = key, languageName = languageName, value = text, idx = index) + } + + private fun validateAndSaveIssues( + keyName: String, + idx: Int, + value: Any?, + stringValue: String?, + ): Boolean { if (keyName.isBlank()) { this.fileEntity.addKeyIsBlankIssue(idx) - return + return false } if (value !is String?) { this.fileEntity.addValueIsNotStringIssue(keyName, idx, value) - return + return false } if (value.isNullOrEmpty()) { @@ -46,19 +129,9 @@ data class FileProcessorContext( if (stringValue != null && stringValue.length > maxTranslationTextLength) { fileEntity.addIssue(FileIssueType.TRANSLATION_TOO_LONG, mapOf(FileIssueParamType.KEY_NAME to keyName)) - return - } - - val language = getOrCreateLanguage(languageName) - - if (translations[keyName] == null) { - translations[keyName] = mutableListOf() - } - - if (value != null) { - val entity = ImportTranslation(stringValue, language) - translations[keyName]!!.add(entity) + return false } + return true } private fun getOrCreateLanguage(languageName: String): ImportLanguage { @@ -67,14 +140,15 @@ data class FileProcessorContext( } } - fun addKeyComment( + fun addKeyDescription( key: String, - text: String, + text: String?, ) { - val keyMeta = getOrCreateKeyMeta(key) - keyMeta.addComment { - this.text = text + if (text.isNullOrBlank()) { + return } + val keyMeta = getOrCreateKeyMeta(key) + keyMeta.description = text.trim() } fun addKeyCodeReference( @@ -89,8 +163,27 @@ data class FileProcessorContext( } } + fun setCustom( + translationKey: String, + customMapKey: String, + value: Any, + ) { + val keyMeta = getOrCreateKeyMeta(translationKey) + keyMeta.setCustom(customMapKey, value) + keyMeta.custom?.let { + if (!customValuesValidator.isValid(it)) { + keyMeta.custom?.remove(customMapKey) + fileEntity.addIssue(FileIssueType.INVALID_CUSTOM_VALUES, mapOf(FileIssueParamType.KEY_NAME to translationKey)) + } + } + } + private fun getOrCreateKey(name: String): ImportKey { - return keys[name] ?: let { ImportKey(name, this.fileEntity).also { keys[name] = it } } + return keys[name] ?: createKey(name) + } + + private fun createKey(name: String): ImportKey { + return ImportKey(name, this.fileEntity).also { keys[name] = it } } private fun getOrCreateKeyMeta(key: String): KeyMeta { @@ -100,4 +193,8 @@ data class FileProcessorContext( keyEntity.keyMeta!! } } + + val customValuesValidator: KeyCustomValuesValidator by lazy { + applicationContext.getBean(KeyCustomValuesValidator::class.java) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/JsonFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/JsonFileProcessor.kt deleted file mode 100644 index 0cb6628294..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/JsonFileProcessor.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.tolgee.service.dataImport.processors - -import com.fasterxml.jackson.core.JsonParseException -import com.fasterxml.jackson.databind.exc.MismatchedInputException -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import io.tolgee.exceptions.ImportCannotParseFileException - -class JsonFileProcessor( - override val context: FileProcessorContext, -) : ImportFileProcessor() { - override fun process() { - try { - val data = jacksonObjectMapper().readValue>(context.file.data) - val parsed = data.parse() - parsed.entries.forEachIndexed { index, it -> - context.addTranslation(it.key, languageNameGuesses[0], it.value, index) - } - } catch (e: JsonParseException) { - throw ImportCannotParseFileException(context.file.name, e.message ?: "") - } catch (e: MismatchedInputException) { - throw ImportCannotParseFileException(context.file.name, e.message ?: "") - } - } - - private fun Map<*, *>.parse(): Map { - val data = mutableMapOf() - this.entries.forEachIndexed { idx, entry -> - val key = entry.key - - if (key !is String) { - context.fileEntity.addKeyIsNotStringIssue(key.toString(), idx) - return@forEachIndexed - } - - (entry.value as? Map<*, *>)?.let { embedded -> - embedded.parse().forEach { embeddedEntry -> - data["$key${context.params.structureDelimiter}${embeddedEntry.key}"] = embeddedEntry.value - } - return@forEachIndexed - } - - val value = entry.value - data[key] = value - return@forEachIndexed - } - return data - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ProcessorFactory.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ProcessorFactory.kt deleted file mode 100644 index 463ad405e3..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/ProcessorFactory.kt +++ /dev/null @@ -1,36 +0,0 @@ -package io.tolgee.service.dataImport.processors - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.exceptions.ImportCannotParseFileException -import io.tolgee.service.dataImport.processors.po.PoFileProcessor -import io.tolgee.service.dataImport.processors.xliff.XliffFileProcessor -import org.springframework.stereotype.Component - -@Component -class ProcessorFactory { - fun getArchiveProcessor(file: ImportFileDto): ImportArchiveProcessor { - return when (file.name.fileNameExtension) { - "zip" -> ZipTypeProcessor() - else -> throw ImportCannotParseFileException(file.name, "No matching processor") - } - } - - fun getProcessor( - file: ImportFileDto, - context: FileProcessorContext, - ): ImportFileProcessor { - return when (file.name.fileNameExtension) { - "json" -> JsonFileProcessor(context) - "po" -> PoFileProcessor(context) - "xliff" -> XliffFileProcessor(context) - "xlf" -> XliffFileProcessor(context) - "properties" -> PropertyFileProcessor(context) - else -> throw ImportCannotParseFileException(file.name, "No matching processor") - } - } - - val String?.fileNameExtension: String? - get() { - return this?.replace(".*\\.(.+)\$".toRegex(), "$1") - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/PropertyFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/PropertyFileProcessor.kt deleted file mode 100644 index cf5b0e7803..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/PropertyFileProcessor.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.tolgee.service.dataImport.processors - -import java.util.* - -class PropertyFileProcessor( - override val context: FileProcessorContext, -) : ImportFileProcessor() { - override fun process() { - val props = Properties() - props.load(context.file.data.inputStream()) - props.entries.forEachIndexed { idx, it -> - context.addTranslation(it.key.toString(), languageNameGuesses[0], it.value, idx) - } - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/SupportedFormat.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/SupportedFormat.kt deleted file mode 100644 index a403f71e8e..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/SupportedFormat.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.tolgee.service.dataImport.processors.messageFormat - -enum class SupportedFormat(val poFlag: String) { - PHP(poFlag = "php-format"), - C(poFlag = "c-format"), - PYTHON(poFlag = "python-format"), - ; - - companion object { - fun findByFlag(poFlag: String) = values().find { it.poFlag == poFlag } - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/ToICUConverter.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/ToICUConverter.kt deleted file mode 100644 index 7672213bf6..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/messageFormat/ToICUConverter.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.tolgee.service.dataImport.processors.messageFormat - -import com.ibm.icu.text.PluralRules -import com.ibm.icu.util.ULocale -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.messageFormat.data.PluralData - -class ToICUConverter( - private val locale: ULocale, - private val format: SupportedFormat?, - private val context: FileProcessorContext, -) { - companion object { - val PHP_PARAM_REGEX = - """ - (?x)( - % - (?:(?\d+)${"\\$"})? - (?(?:[-+\s0]|'.)+)? - (?\d+)? - (?.\d+)? - (?[bcdeEfFgGhHosuxX%]) - ) - """.trimIndent().toRegex() - - val C_PARAM_REGEX = - """ - (?x)( - % - (?:(?\d+)${"\\$"})? - (?[-+\s0\#]+)? - (?\d+)? - (?.\d+)? - (?hh|h|l|ll|j|z|t|L)? - (?[diuoxXfFeEgGaAcspn%]) - ) - """.trimIndent().toRegex() - - val PYTHON_PARAM_REGEX = - """ - (?x)( - % - (?:\((?[\w-]+)\))? - (?[-+\s0\#]+)? - (?[\d*]+)? - (?.[\d*]+)? - (?[hlL])? - (?[diouxXeEfFgGcrs%]) - ) - """.trimIndent().toRegex() - - val PHP_NUMBER_SPECIFIERS = "dfeEfFgGhH" - val C_NUMBER_SPECIFIERS = "diuoxXfFeEgG" - val PYTHON_NUMBER_SPECIFIERS = "diouxXeEfFgG" - } - - fun convert(message: String): String { - return when (format) { - SupportedFormat.PHP -> convertPhp(message) - SupportedFormat.C -> convertC(message) - SupportedFormat.PYTHON -> convertPython(message) - else -> convertC(message) - } - } - - fun convertPoPlural(pluralForms: Map): String { - val icuMsg = StringBuffer("{0, plural,\n") - pluralForms.entries.forEach { (key, value) -> - val example = findSuitableExample(key) - val keyword = PluralRules.forLocale(locale).select(example.toDouble()) - icuMsg.append("$keyword {${convert(value)}}\n") - } - icuMsg.append("}") - return icuMsg.toString() - } - - private fun findSuitableExample(key: Int): Int { - val examples = PluralData.DATA[locale.language]?.examples ?: PluralData.DATA["en"]!!.examples - return examples.find { it.plural == key }?.sample ?: examples[0].sample - } - - private fun convertPhp(message: String): String { - return convertCLike(message, PHP_PARAM_REGEX, PHP_NUMBER_SPECIFIERS) - } - - private fun convertC(message: String): String { - return convertCLike(message, C_PARAM_REGEX, C_NUMBER_SPECIFIERS) - } - - private fun convertPython(message: String): String { - return convertCLike(message, PYTHON_PARAM_REGEX, PYTHON_NUMBER_SPECIFIERS) - } - - private fun convertCLike( - message: String, - regex: Regex, - numberSpecifiers: String, - ): String { - var result = message - var keyIdx = 0 - - result = - result.replace(regex) { - var paramName = keyIdx.toString() - val specifier = it.groups["specifier"]!!.value - - if (specifier == "%") { - return@replace "%" - } - - it.groups.getGroupOrNull("argnum")?.let { grp -> - paramName = (grp.value.toInt() - 1).toString() - } - - it.groups.getGroupOrNull("argname")?.let { grp -> - paramName = grp.value - } - - val typeStr = if (numberSpecifiers.contains(specifier)) ", number" else "" - keyIdx++ - "{${paramName}$typeStr}" - } - - return result - } - - fun MatchGroupCollection.getGroupOrNull(name: String): MatchGroup? { - try { - return this[name] - } catch (e: IllegalArgumentException) { - if (e.message?.contains("No group with name") != true) { - throw e - } - return null - } - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/Xliff12FileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/Xliff12FileProcessor.kt deleted file mode 100644 index 382d5c559a..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/Xliff12FileProcessor.kt +++ /dev/null @@ -1,140 +0,0 @@ -package io.tolgee.service.dataImport.processors.xliff - -import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType -import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.ImportFileProcessor -import java.io.StringWriter -import java.util.* -import javax.xml.namespace.QName -import javax.xml.stream.XMLEventReader -import javax.xml.stream.XMLEventWriter -import javax.xml.stream.XMLOutputFactory -import javax.xml.stream.events.StartElement - -class Xliff12FileProcessor( - override val context: FileProcessorContext, - val xmlEventReader: XMLEventReader, -) : ImportFileProcessor() { - private val openElements = mutableListOf("xliff") - private val currentOpenElement: String? - get() = openElements.lastOrNull() - - private var sw = StringWriter() - private val of: XMLOutputFactory = XMLOutputFactory.newDefaultFactory() - private var xw: XMLEventWriter? = null - - override fun process() { - var fileOriginal: String? = null - var sourceLanguage: String? = null - var targetLanguage: String? = null - var id: String? = null - var currentTextValue: String? = null - var targetProvided = false - var idx = 0 - - while (xmlEventReader.hasNext()) { - val event = xmlEventReader.nextEvent() - when { - event.isStartElement -> { - if (!isAnyToContentSaveOpen) { - sw = StringWriter() - xw = of.createXMLEventWriter(sw) - } - (event as? StartElement)?.let { startElement -> - openElements.add(startElement.name.localPart.lowercase(Locale.getDefault())) - when (currentOpenElement) { - "file" -> { - fileOriginal = - startElement - .getAttributeByName(QName(null, "original"))?.value - sourceLanguage = - startElement - .getAttributeByName(QName(null, "source-language"))?.value - targetLanguage = - startElement - .getAttributeByName(QName(null, "target-language"))?.value - } - "trans-unit" -> { - id = startElement.getAttributeByName(QName(null, "id"))?.value - if (id != null && fileOriginal != null) { - context.addKeyCodeReference(id!!, fileOriginal!!, null) - } - if (fileOriginal != null && id == null) { - context.fileEntity.addIssue( - FileIssueType.ID_ATTRIBUTE_NOT_PROVIDED, - mapOf(FileIssueParamType.FILE_NODE_ORIGINAL to fileOriginal!!), - ) - } - } - } - } - } - event.isCharacters -> { - if (currentOpenElement != null) { - when (currentOpenElement!!) { - in "source", "target", "note" -> { - currentTextValue = (currentTextValue ?: "") + event.asCharacters().data - } - } - } - } - event.isEndElement -> - if (event.isEndElement) { - when (currentOpenElement) { - "file" -> { - idx = 0 - fileOriginal = null - sourceLanguage = null - targetLanguage = null - } - "trans-unit" -> { - idx++ - if (id != null && !targetProvided) { - context.fileEntity.addIssue( - FileIssueType.TARGET_NOT_PROVIDED, - mapOf(FileIssueParamType.KEY_NAME to id!!), - ) - id = null - } - targetProvided = false - } - "source" -> { - if (sourceLanguage != null && id != null) { - context.addTranslation(id!!, sourceLanguage!!, sw.toString(), idx) - } - } - "target" -> { - if (targetLanguage != null && id != null) { - targetProvided = true - context.addTranslation(id!!, targetLanguage!!, sw.toString(), idx) - } - } - "note" -> { - if (sw.toString().isNotBlank() && id != null) { - context.addKeyComment(id!!, sw.toString()) - } - } - } - currentTextValue = null - openElements.removeLast() - } - } - if (isAnyToContentSaveOpen) { - val startName = (event as? StartElement)?.name?.localPart?.lowercase(Locale.getDefault()) - if (!contentSaveElements.contains(startName)) { - xw?.add(event) - } - } else { - xw?.close() - } - } - } - - private val isAnyToContentSaveOpen - get() = openElements.any { contentSaveElements.contains(it) } - - companion object { - private val contentSaveElements = listOf("source", "target", "note") - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/XliffFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/XliffFileProcessor.kt deleted file mode 100644 index e92b4766be..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/processors/xliff/XliffFileProcessor.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.tolgee.service.dataImport.processors.xliff - -import io.tolgee.exceptions.ImportCannotParseFileException -import io.tolgee.exceptions.UnsupportedXliffVersionException -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.ImportFileProcessor -import java.util.* -import javax.xml.namespace.QName -import javax.xml.stream.XMLEventReader -import javax.xml.stream.XMLInputFactory -import javax.xml.stream.events.StartElement - -class XliffFileProcessor(override val context: FileProcessorContext) : ImportFileProcessor() { - override fun process() { - try { - when (version) { - "1.2" -> Xliff12FileProcessor(context, xmlEventReader).process() - else -> throw UnsupportedXliffVersionException(version) - } - } catch (e: Exception) { - throw ImportCannotParseFileException(context.file.name, e.message) - } - } - - private val xmlEventReader: XMLEventReader by lazy { - val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() - inputFactory.createXMLEventReader(context.file.data.inputStream()) - } - - private val version: String by lazy { - while (xmlEventReader.hasNext()) { - val event = xmlEventReader.nextEvent() - if (event.isStartElement && - (event as? StartElement)?.name?.localPart?.lowercase(Locale.getDefault()) == "xliff" - ) { - val versionAttr = event.getAttributeByName(QName(null, "version")) - if (versionAttr != null) { - return@lazy versionAttr.value - } - } - } - throw ImportCannotParseFileException(context.file.name, "No version information") - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatus.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatus.kt index e0d74bebca..95254ad33f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatus.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatus.kt @@ -5,4 +5,5 @@ enum class ImportApplicationStatus { STORING_KEYS, STORING_TRANSLATIONS, FINALIZING, + ERROR, } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatusItem.kt b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatusItem.kt index 62400956b3..d5d74f2bad 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatusItem.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/dataImport/status/ImportApplicationStatusItem.kt @@ -1,5 +1,9 @@ package io.tolgee.service.dataImport.status +import io.tolgee.exceptions.ErrorResponseBody + data class ImportApplicationStatusItem( val status: ImportApplicationStatus, + val errorStatusCode: Int? = null, + val errorResponseBody: ErrorResponseBody? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/ExportService.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/ExportService.kt index 3ca126d053..7b879b60a7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/ExportService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/ExportService.kt @@ -25,6 +25,7 @@ class ExportService( ): Map { val data = getDataForExport(projectId, exportParams) val baseLanguage = getProjectBaseLanguage(projectId) + val project = projectService.get(projectId) val baseTranslationsProvider = getBaseTranslationsProvider( exportParams = exportParams, @@ -37,6 +38,7 @@ class ExportService( exportParams = exportParams, baseTranslationsProvider = baseTranslationsProvider, baseLanguage, + projectIcuPlaceholdersSupport = project.icuPlaceholders, ).produceFiles().also { businessEventPublisher.publishOnceInTime( OnBusinessEventToCaptureEvent( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt index ed01ea6648..07c7bbf770 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt @@ -1,25 +1,110 @@ package io.tolgee.service.export +import com.fasterxml.jackson.databind.ObjectMapper +import io.tolgee.constants.Message import io.tolgee.dtos.IExportParams import io.tolgee.dtos.cacheable.LanguageDto -import io.tolgee.dtos.request.export.ExportFormat +import io.tolgee.exceptions.BadRequestException +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.ExportMessageFormat +import io.tolgee.formats.android.out.AndroidStringsXmlExporter +import io.tolgee.formats.apple.out.AppleStringsStringsdictExporter +import io.tolgee.formats.apple.out.AppleXliffExporter +import io.tolgee.formats.flutter.out.FlutterArbFileExporter +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.formats.json.out.JsonFileExporter +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.formats.po.out.PoFileExporter +import io.tolgee.formats.properties.out.PropertiesFileExporter +import io.tolgee.formats.xliff.out.XliffFileExporter import io.tolgee.service.export.dataProvider.ExportTranslationView import io.tolgee.service.export.exporters.FileExporter -import io.tolgee.service.export.exporters.JsonFileExporter -import io.tolgee.service.export.exporters.XliffFileExporter import org.springframework.stereotype.Component @Component -class FileExporterFactory { +class FileExporterFactory( + private val objectMapper: ObjectMapper, +) { fun create( data: List, exportParams: IExportParams, baseTranslationsProvider: () -> List, baseLanguage: LanguageDto, + projectIcuPlaceholdersSupport: Boolean, ): FileExporter { return when (exportParams.format) { - ExportFormat.JSON -> JsonFileExporter(data, exportParams) - ExportFormat.XLIFF -> XliffFileExporter(data, exportParams, baseTranslationsProvider, baseLanguage) + ExportFormat.JSON -> + JsonFileExporter( + data, + exportParams, + ) { text, isPlural -> + IcuToGenericFormatMessageConvertor(text, isPlural, projectIcuPlaceholdersSupport).convert() + } + + ExportFormat.XLIFF -> + XliffFileExporter( + data, + exportParams, + baseTranslationsProvider, + baseLanguage, + ) { text, isPlural -> + IcuToGenericFormatMessageConvertor(text, isPlural, projectIcuPlaceholdersSupport).convert() + } + + ExportFormat.APPLE_XLIFF -> + AppleXliffExporter( + data, + exportParams, + baseTranslationsProvider, + baseLanguage.tag, + projectIcuPlaceholdersSupport, + ) + + ExportFormat.ANDROID_XML -> AndroidStringsXmlExporter(data, exportParams, projectIcuPlaceholdersSupport) + ExportFormat.PO -> + getPoExporter(data, exportParams, baseTranslationsProvider, baseLanguage, projectIcuPlaceholdersSupport) + + ExportFormat.APPLE_STRINGS_STRINGSDICT -> + AppleStringsStringsdictExporter(data, exportParams, projectIcuPlaceholdersSupport) + + ExportFormat.FLUTTER_ARB -> + FlutterArbFileExporter( + data, + exportParams, + baseLanguage.tag, + objectMapper, + projectIcuPlaceholdersSupport, + ) + + ExportFormat.PROPERTIES -> + PropertiesFileExporter(data, exportParams) { text, isPlural -> + IcuToGenericFormatMessageConvertor(text, isPlural, projectIcuPlaceholdersSupport).convert() + } } } + + private fun getPoExporter( + data: List, + exportParams: IExportParams, + baseTranslationsProvider: () -> List, + baseLanguage: LanguageDto, + projectIcuPlaceholdersSupport: Boolean, + ): PoFileExporter { + val poSupportedMessageFormat = + when (exportParams.messageFormat) { + null -> PoSupportedMessageFormat.C + ExportMessageFormat.PHP_SPRINTF -> PoSupportedMessageFormat.PHP + ExportMessageFormat.C_SPRINTF -> PoSupportedMessageFormat.C +// ExportMessageFormat.PYTHON_SPRINTF -> PoSupportedMessageFormat.PYTHON + else -> throw BadRequestException(Message.UNSUPPORTED_PO_MESSAGE_FORMAT) + } + return PoFileExporter( + data, + exportParams, + baseTranslationsProvider, + baseLanguage, + poSupportedMessageFormat, + projectIcuPlaceholdersSupport, + ) + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataProvider.kt index 0a17fb4336..740550e979 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataProvider.kt @@ -68,7 +68,10 @@ class ExportDataProvider( query.multiselect( key.get(Key_.id), key.get(Key_.name), + keyMetaJoin.get(KeyMeta_.custom), + keyMetaJoin.get(KeyMeta_.description), namespaceJoin.get(Namespace_.name), + key.get(Key_.isPlural), languageJoin.get(Language_.id), languageJoin.get(Language_.tag), translationJoin.get(Translation_.id), @@ -177,7 +180,15 @@ class ExportDataProvider( resultList.forEach { dataView -> val keyView = keyMap.computeIfAbsent(dataView.keyId) { - ExportKeyView(dataView.keyId, dataView.keyName, dataView.namespace) + @Suppress("UNCHECKED_CAST") + ExportKeyView( + dataView.keyId, + dataView.keyName, + dataView.keyCustom as? Map?, + dataView.keyDescription, + dataView.namespace, + dataView.keyIsPlural, + ) } keyView.translations[dataView.languageTag] = diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataView.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataView.kt index 74fb816a92..c10869d7cf 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportDataView.kt @@ -5,7 +5,10 @@ import io.tolgee.model.enums.TranslationState data class ExportDataView( val keyId: Long, val keyName: String, + val keyCustom: Any?, + val keyDescription: String?, val namespace: String?, + val keyIsPlural: Boolean, val languageId: Long, val languageTag: String, val translationId: Long?, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportKeyView.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportKeyView.kt index 5a3c61692c..93e05c897f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportKeyView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportKeyView.kt @@ -1,8 +1,11 @@ package io.tolgee.service.export.dataProvider class ExportKeyView( - val id: Long, - val name: String, - val namespace: String? = null, + var id: Long = 0, + var name: String, + var custom: Map? = null, + var description: String? = null, + var namespace: String? = null, + var isPlural: Boolean = false, val translations: MutableMap = mutableMapOf(), ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportTranslationView.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportTranslationView.kt index a260f62e06..eb3e2b178b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportTranslationView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/dataProvider/ExportTranslationView.kt @@ -5,7 +5,7 @@ import io.tolgee.model.enums.TranslationState class ExportTranslationView( val id: Long?, val text: String?, - val state: TranslationState, + val state: TranslationState = TranslationState.TRANSLATED, val key: ExportKeyView, - val languageTag: String, + val languageTag: String = "en", ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/FileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/FileExporter.kt index de84e3a4c7..e9e9a86458 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/FileExporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/FileExporter.kt @@ -11,8 +11,15 @@ interface FileExporter { fun produceFiles(): Map - fun ExportTranslationView.getFilePath(namespace: String?): String { - val filename = "${this.languageTag}.$fileExtension" + fun ExportTranslationView.getFilePath(): String { + return getFilePath(this.key.namespace, fileExtension) + } + + fun ExportTranslationView.getFilePath( + namespace: String?, + extension: String, + ): String { + val filename = "${this.languageTag}.$extension" val filePath = namespace ?: "" return "$filePath/$filename".replace("^/".toRegex(), "") } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/JsonFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/JsonFileExporter.kt deleted file mode 100644 index 7073a301c9..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/JsonFileExporter.kt +++ /dev/null @@ -1,89 +0,0 @@ -package io.tolgee.service.export.exporters - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.tolgee.dtos.IExportParams -import io.tolgee.dtos.request.export.ExportFormat -import io.tolgee.helpers.TextHelper -import io.tolgee.service.export.dataProvider.ExportTranslationView -import java.io.InputStream - -class JsonFileExporter( - override val translations: List, - override val exportParams: IExportParams, -) : FileExporter { - override val fileExtension: String = ExportFormat.JSON.extension - - val result: LinkedHashMap> = LinkedHashMap() - - override fun produceFiles(): Map { - prepare() - return result.asSequence().map { (fileName, json) -> - fileName to jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(json).inputStream() - }.toMap() - } - - private fun prepare() { - translations.forEach { translation -> - val path = TextHelper.splitOnNonEscapedDelimiter(translation.key.name, exportParams.structureDelimiter) - val fileContentResultMap = getFileContentResultMap(translation) - addToMap(fileContentResultMap, path, translation) - } - } - - private fun getFileContentResultMap(translation: ExportTranslationView): LinkedHashMap { - val absolutePath = translation.getFilePath(translation.key.namespace) - return result[absolutePath] ?: let { - LinkedHashMap().also { result[absolutePath] = it } - } - } - - private fun addToMap( - content: LinkedHashMap, - pathItems: List, - translation: ExportTranslationView, - ) { - val pathItemsMutable = pathItems.toMutableList() - val pathItem = pathItemsMutable.removeFirst() - if (pathItemsMutable.size > 0) { - val map = - content[pathItem] ?: LinkedHashMap().also { - content[pathItem] = it - } - - if (map !is Map<*, *>) { - handleExistingStringScopeCollision(pathItems, content, translation) - return - } - - @Suppress("UNCHECKED_CAST") - addToMap(map as LinkedHashMap, pathItemsMutable, translation) - return - } - - content.putTranslationText(pathItem, translation) - } - - private fun handleExistingStringScopeCollision( - pathItems: List, - content: LinkedHashMap, - translation: ExportTranslationView, - ) { - val delimiter = exportParams.structureDelimiter.toString() - val last2joined = pathItems.takeLast(2).joinToString(delimiter) - val joinedPathItems = pathItems.dropLast(2) + last2joined - addToMap(content, joinedPathItems, translation) - } - - private fun LinkedHashMap.putTranslationText( - key: String, - translation: ExportTranslationView, - ) { - val value = translation.text - - if (this.containsKey(key)) { - throw StringScopeCollisionException() - } - - this[key] = value - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/XliffFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/XliffFileExporter.kt deleted file mode 100644 index 9a9d2c17f0..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/exporters/XliffFileExporter.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.tolgee.service.export.exporters - -import io.tolgee.dtos.IExportParams -import io.tolgee.dtos.request.export.ExportFormat -import io.tolgee.helpers.TextHelper -import io.tolgee.model.ILanguage -import io.tolgee.service.export.dataProvider.ExportTranslationView -import org.dom4j.Document -import org.dom4j.DocumentException -import org.dom4j.DocumentHelper -import org.dom4j.Element -import org.dom4j.Node -import org.dom4j.io.OutputFormat -import org.dom4j.io.XMLWriter -import java.io.ByteArrayOutputStream -import java.io.InputStream - -class XliffFileExporter( - override val translations: List, - override val exportParams: IExportParams, - baseTranslationsProvider: () -> List, - val baseLanguage: ILanguage, -) : FileExporter { - override val fileExtension: String = ExportFormat.XLIFF.extension - - val result = mutableMapOf() - private val baseTranslations: Map - - init { - this.baseTranslations = baseTranslationsProvider().associateBy { it.key.name } - } - - override fun produceFiles(): Map { - prepare() - return result.asSequence().map { (fileName, resultItem) -> - val outputStream = ByteArrayOutputStream() - val writer = XMLWriter(outputStream, OutputFormat.createPrettyPrint()) - writer.write(resultItem.document) - fileName to outputStream.toByteArray().inputStream() - }.toMap() - } - - private fun prepare() { - translations.forEach { translation -> - val path = TextHelper.splitOnNonEscapedDelimiter(translation.key.name, exportParams.structureDelimiter) - val resultItem = getResultItem(translation) - addToFileElement(resultItem.fileBodyElement, path, translation) - } - } - - private fun addToFileElement( - fileBodyElement: Element, - pathItems: List, - translation: ExportTranslationView, - ) { - val transUnitElement = - fileBodyElement.addElement("trans-unit") - .addAttribute("id", pathItems.joinToString(exportParams.structureDelimiter.toString())) - .addAttribute("datatype", "html") - - baseTranslations[translation.key.name]?.text?.let { - transUnitElement.addElement("source").addFromHtmlOrText(it) - } - translation.text?.let { - transUnitElement.addElement("target").addFromHtmlOrText(it) - } - } - - private fun getResultItem(translation: ExportTranslationView): ResultItem { - val absolutePath = translation.getFilePath(translation.key.namespace) - return result[absolutePath] ?: let { - val resultItem = createBaseDocumentStructure(translation) - result[absolutePath] = resultItem - return resultItem - } - } - - private fun createBaseDocumentStructure(translation: ExportTranslationView): ResultItem { - val document = DocumentHelper.createDocument() - document.xmlEncoding = "UTF-8" - val fileBodyElement = - document.addElement("xliff") - .addNamespace("", "urn:oasis:names:tc:xliff:document:1.2") - .addAttribute("version", "1.2") - .addElement("file", "urn:oasis:names:tc:xliff:document:1.2") - .addAttribute("original", "undefined") - .addAttribute("datatype", "plaintext") - .addAttribute("source-language", baseLanguage.tag) - .addAttribute("target-language", translation.languageTag) - .addElement("body") - return ResultItem(document, fileBodyElement) - } - - private fun String.parseHtml(): MutableIterator { - val fragment = - DocumentHelper - .parseText("$this") - return fragment.rootElement.nodeIterator() - } - - /** - * For string containing something, which is not parseable as xml such as - * "Value has to be < 1" - * It just appends text. - */ - private fun Element.addFromHtmlOrText(string: String) { - try { - string.parseHtml().forEach { node -> - if (node !is Node) return@forEach - node.parent = null - this.add(node) - } - } catch (e: DocumentException) { - this.addText(string) - } - } - - data class ResultItem(val document: Document, val fileBodyElement: Element) -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyMetaService.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyMetaService.kt index 2f576c718c..906b887d85 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyMetaService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyMetaService.kt @@ -37,6 +37,7 @@ class KeyMetaService( fun import( target: KeyMeta, source: KeyMeta, + overrideDescriptions: Boolean = true, ) { target.comments.import(target, source.comments.toList()) { a, b -> a.text == b.text && a.fromImport == b.fromImport @@ -44,6 +45,17 @@ class KeyMetaService( target.codeReferences.import(target, source.codeReferences.toList()) { a, b -> a.line == b.line && a.path == b.path } + source.custom?.let { sourceCustom -> + val targetCustom = + target.custom ?: mutableMapOf() + .also { + target.custom = it + } + targetCustom.putAll(sourceCustom) + } + if (overrideDescriptions || target.description.isNullOrEmpty()) { + target.description = source.description + } } private inline fun List.import( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt index 34cb2d01ae..66672d1800 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt @@ -17,6 +17,7 @@ import io.tolgee.model.Project import io.tolgee.model.Screenshot import io.tolgee.model.enums.TranslationState import io.tolgee.model.key.Key +import io.tolgee.model.translation.Translation import io.tolgee.repository.KeyRepository import io.tolgee.repository.LanguageRepository import io.tolgee.service.bigMeta.BigMetaService @@ -86,6 +87,13 @@ class KeyService( return keyRepository.findByIdOrNull(id) ?: throw NotFoundException(Message.KEY_NOT_FOUND) } + fun getView( + projectId: Long, + id: Long, + ): KeyView { + return keyRepository.findView(projectId, id) ?: throw NotFoundException(Message.KEY_NOT_FOUND) + } + fun find(id: Long): Key? { return keyRepository.findById(id).orElse(null) } @@ -109,13 +117,12 @@ class KeyService( dto: CreateKeyDto, ): Key { val key = create(project, dto.name, dto.namespace) - val created = - dto.translations?.let { - if (it.isEmpty()) { - return@let null - } - translationService.setForKey(key, it) - } + key.isPlural = dto.isPlural + if (key.isPlural) { + key.pluralArgName = dto.pluralArgName + } + + val created = createTranslationsOnKeyCreate(dto, key) dto.states?.map { val translation = @@ -139,6 +146,16 @@ class KeyService( return key } + private fun createTranslationsOnKeyCreate( + dto: CreateKeyDto, + key: Key, + ): Map? { + if (dto.translations.isNullOrEmpty()) { + return null + } + return translationService.setForKey(key, dto.translations!!) + } + private fun storeScreenshots( dto: CreateKeyDto, key: Key, @@ -167,9 +184,10 @@ class KeyService( project: Project, name: String, namespace: String?, + isPlural: Boolean = false, ): Key { checkKeyNotExisting(projectId = project.id, name = name, namespace = namespace) - return createWithoutExistenceCheck(project, name, namespace) + return createWithoutExistenceCheck(project, name, namespace, isPlural) } @Transactional @@ -177,8 +195,9 @@ class KeyService( project: Project, name: String, namespace: String?, + isPlural: Boolean, ): Key { - val key = Key(name = name, project = project) + val key = Key(name = name, project = project).apply { this.isPlural = isPlural } if (!namespace.isNullOrBlank()) { key.namespace = namespaceService.findOrCreate(namespace, project.id) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/ResolvingKeyImporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/ResolvingKeyImporter.kt index 908d7c1c86..7e3b17aa36 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/ResolvingKeyImporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/ResolvingKeyImporter.kt @@ -9,6 +9,8 @@ import io.tolgee.dtos.request.translation.importKeysResolvable.ImportTranslation import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException +import io.tolgee.formats.convertToIcuPlurals +import io.tolgee.formats.convertToPluralIfAnyIsPlural import io.tolgee.model.Language import io.tolgee.model.Project import io.tolgee.model.Project_ @@ -49,6 +51,8 @@ class ResolvingKeyImporter( private val errors = mutableListOf>() private var importedKeys: List = emptyList() + private val updatedTranslationIds = mutableListOf() + private val isPluralChangedForKeys = mutableMapOf() operator fun invoke(): KeyImportResolvableResult { importedKeys = tryImport() @@ -61,39 +65,96 @@ class ResolvingKeyImporter( private fun tryImport(): List { checkLanguagePermissions(keysToImport) - return keysToImport.map keys@{ keyToImport -> - val key = getOrCreateKey(keyToImport) + val result = + keysToImport.map keys@{ keyToImport -> + val (key, isKeyNew) = getOrCreateKey(keyToImport) + val isExistingKeyPlural = key.isPlural + val translationsToModify = mutableListOf() - keyToImport.mapLanguageAsKey().forEach translations@{ (language, resolvable) -> - language ?: throw NotFoundException(Message.LANGUAGE_NOT_FOUND) - val existingTranslation = getExistingTranslation(key, language.tag) + keyToImport.mapLanguageAsKey().forEach translations@{ (language, resolvable) -> + language ?: throw NotFoundException(Message.LANGUAGE_NOT_FOUND) - val isEmpty = existingTranslation !== null && existingTranslation.text.isNullOrEmpty() + val existingTranslation = getExistingTranslation(key, language.tag) - val isNew = existingTranslation == null + val isEmpty = existingTranslation !== null && existingTranslation.text.isNullOrEmpty() - val translationExists = !isEmpty && !isNew + val isNew = existingTranslation == null - if (validate(translationExists, resolvable, key, language.tag)) return@translations + val translationExists = !isEmpty && !isNew - if (isEmpty || (!isNew && resolvable.resolution == ImportTranslationResolution.OVERRIDE)) { - translationService.setTranslation(existingTranslation!!, resolvable.text) - return@translations - } + if (validate(translationExists, resolvable, key, language.tag)) return@translations + + if (isEmpty || (!isNew && resolvable.resolution == ImportTranslationResolution.OVERRIDE)) { + translationsToModify.add(TranslationToModify(existingTranslation!!, resolvable.text, false)) + return@translations + } - if (isNew) { - val translation = - Translation(resolvable.text).apply { - this.key = key - this.language = entityManager.getReference(Language::class.java, language.id) - } - translationService.save(translation) + if (isNew) { + val translation = + Translation(resolvable.text).apply { + this.key = key + this.language = entityManager.getReference(Language::class.java, language.id) + } + translationsToModify.add(TranslationToModify(translation, resolvable.text, true)) + } } + + handlePluralizationAndSave(isExistingKeyPlural, translationsToModify, key) + key + } + + translationService.onKeyIsPluralChanged(isPluralChangedForKeys, true, updatedTranslationIds) + + return result + } + + private fun handlePluralizationAndSave( + isExistingKeyPlural: Boolean, + translationsToModify: MutableList, + key: Key, + ) { + val translationsToModifyMap = translationsToModify.associateWith { it.text } + + // when existing key is plural, we are converting all to plurals + if (isExistingKeyPlural) { + translationsToModifyMap.convertToIcuPlurals(null).convertedStrings.forEach { + it.key.text = it.value } - key + translationsToModify.save() + return + } + + val convertedToPlurals = + translationsToModifyMap.convertToPluralIfAnyIsPlural() + + // if anything from the new translations is plural, we are converting the key to plural + if (convertedToPlurals != null) { + key.isPlural = true + keyService.save(key) + translationsToModify.forEach { translation -> + translation.text = convertedToPlurals.convertedStrings[translation] + } + // now we have to also handle translations of keys, + // which are already existing in the database + isPluralChangedForKeys[key.id] = convertedToPlurals.argName + } + + translationsToModify.save() + } + + private fun List.save() { + this.forEach { + translationService.setTranslation(it.translation, it.text) + updatedTranslationIds.add(it.translation.id) } } + class TranslationToModify( + val translation: Translation, + var text: String?, + val isNew: Boolean, + ) + private fun importScreenshots(): Map { val uploadedImagesIds = keysToImport.flatMap { @@ -127,7 +188,7 @@ class ResolvingKeyImporter( val info = ScreenshotInfoDto(screenshot.text, screenshot.positions) screenshotService.addReference( - key = key, + key = key.first, screenshot = screenshotResult.screenshot, info = info, originalDimension = screenshotResult.originalDimension, @@ -136,7 +197,7 @@ class ResolvingKeyImporter( val toDelete = allReferences.filter { reference -> - reference.key.id == key.id && + reference.key.id == key.first.id && reference.screenshot.location == screenshotResult.screenshot.location } @@ -211,15 +272,21 @@ class ResolvingKeyImporter( languages[languageTag] to value } - private fun getOrCreateKey(keyToImport: ImportKeysResolvableItemDto) = - existingKeys.computeIfAbsent(keyToImport.namespace to keyToImport.name) { - securityService.checkProjectPermission(projectEntity.id, Scope.KEYS_CREATE) - keyService.createWithoutExistenceCheck( - name = keyToImport.name, - namespace = keyToImport.namespace, - project = projectEntity, - ) - } + private fun getOrCreateKey(keyToImport: ImportKeysResolvableItemDto): Pair { + var isNew = false + val key = + existingKeys.computeIfAbsent(keyToImport.namespace to keyToImport.name) { + isNew = true + securityService.checkProjectPermission(projectEntity.id, Scope.KEYS_CREATE) + keyService.createWithoutExistenceCheck( + project = projectEntity, + name = keyToImport.name, + namespace = keyToImport.namespace, + isPlural = false, + ) + } + return key to isNew + } private fun getAllByNamespaceAndName( projectId: Long, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt index 350e72a133..fe6b845787 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt @@ -1,6 +1,7 @@ package io.tolgee.service.key.utils import io.tolgee.dtos.request.translation.ImportKeysItemDto +import io.tolgee.formats.convertToPluralIfAnyIsPlural import io.tolgee.model.Project import io.tolgee.model.key.Key import io.tolgee.model.key.KeyMeta @@ -59,8 +60,13 @@ class KeysImporter( } this.namespace = namespaces[safeNamespace] } + + val convertedToPlurals = keyDto.translations.convertToPluralIfAnyIsPlural()?.convertedStrings + key.isPlural = convertedToPlurals != null keyService.save(key) - keyDto.translations.entries.forEach { (languageTag, value) -> + + val translations = convertedToPlurals ?: keyDto.translations + translations.entries.forEach { (languageTag, value) -> languages[languageTag]?.let { language -> translationService.setTranslation(key, language, value) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/KeyForMt.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/KeyForMt.kt index a0aa5ca6c2..5e4821579f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/KeyForMt.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/KeyForMt.kt @@ -5,5 +5,6 @@ data class KeyForMt( val name: String, val namespace: String?, val description: String?, - val baseTranslation: String?, + var baseTranslation: String?, + var isPlural: Boolean, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt index caa4c01ab7..8e4060167b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MetadataProvider.kt @@ -6,12 +6,10 @@ import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.service.bigMeta.BigMetaService import io.tolgee.service.translation.TranslationService import jakarta.persistence.EntityManager -import org.springframework.context.ApplicationContext import org.springframework.data.domain.PageRequest class MetadataProvider( private val context: MtTranslatorContext, - private val applicationContext: ApplicationContext, ) { fun get(metadataKey: MetadataKey): Metadata { val closeKeyIds = metadataKey.keyId?.let { bigMetaService.getCloseKeyIds(it) } @@ -23,13 +21,14 @@ class MetadataProvider( examples = getExamples( targetLanguage, + context.getKey(metadataKey.keyId)?.isPlural ?: false, metadataKey.baseTranslationText, metadataKey.keyId, ), closeItems = closeKeyIds?.let { getCloseItems( - context.getBaseLanguage(), + context.baseLanguage, targetLanguage, it, metadataKey.keyId, @@ -74,11 +73,13 @@ class MetadataProvider( private fun getExamples( targetLanguage: LanguageDto, + isPlural: Boolean, text: String, keyId: Long?, ): List { return translationService.getTranslationMemorySuggestions( sourceTranslationText = text, + isPlural = isPlural, key = null, targetLanguage = targetLanguage, pageable = PageRequest.of(0, 5), @@ -95,14 +96,14 @@ class MetadataProvider( } private val bigMetaService: BigMetaService by lazy { - applicationContext.getBean(BigMetaService::class.java) + context.applicationContext.getBean(BigMetaService::class.java) } private val translationService: TranslationService by lazy { - applicationContext.getBean(TranslationService::class.java) + context.applicationContext.getBean(TranslationService::class.java) } private val entityManager: EntityManager by lazy { - applicationContext.getBean(EntityManager::class.java) + context.applicationContext.getBean(EntityManager::class.java) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslator.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslator.kt index 440f87cd9c..b1959bb12f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslator.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslator.kt @@ -1,13 +1,15 @@ package io.tolgee.service.machineTranslation -import io.tolgee.component.machineTranslation.MtServiceManager +import io.tolgee.component.machineTranslation.TranslateResult import io.tolgee.component.machineTranslation.TranslationParams +import io.tolgee.formats.DEFAULT_PLURAL_ARGUMENT_NAME +import io.tolgee.formats.forceEscapePluralForms +import io.tolgee.formats.toIcuPluralString +import io.tolgee.formats.unescapePluralForms import io.tolgee.helpers.TextHelper -import org.springframework.context.ApplicationContext class MtBatchTranslator( private val context: MtTranslatorContext, - private val applicationContext: ApplicationContext, ) { fun translate(batch: List): List { val result = mutableListOf() @@ -27,19 +29,114 @@ class MtBatchTranslator( return getEmptyResult(item) } + if (isExistingKeyPlural(item) || isDetectedPlural(item.keyId, baseTranslationText)) { + return translatePlural(item, baseTranslationText) + } + + return translateInSingleRequest(item, baseTranslationText) + } + + private fun translatePlural( + item: MtBatchItemParams, + baseTranslationText: String, + ): MtTranslatorResult { + val preparedText = + if (context.project.icuPlaceholders) baseTranslationText else baseTranslationText.unescapePluralForms() ?: "" + + val translated = + if (item.service.supportsPlurals) { + translateInSingleRequest(item, preparedText, isPlural = true) + } else { + translatePluralSeparately(item, preparedText) + } + + translated.translatedText = + if (context.project.icuPlaceholders) { + translated.translatedText + } else { + translated.translatedText?.forceEscapePluralForms() + } + + return translated + } + + private fun translatePluralSeparately( + item: MtBatchItemParams, + baseTranslationText: String, + ): MtTranslatorResult { + return PluralTranslationUtil(context, baseTranslationText, item) { prepared -> + translateInSingleRequest(item, prepared) + }.translate() + } + + private fun isDetectedPlural( + keyId: Long?, + baseTranslationText: String, + ): Boolean { + if (keyId != null) { + return false + } + return context.getPluralForms(baseTranslationText) != null + } + + private fun isExistingKeyPlural(item: MtBatchItemParams) = context.keys[item.keyId]?.isPlural == true + + private fun translateInSingleRequest( + item: MtBatchItemParams, + baseTranslationText: String, + isPlural: Boolean = false, + ): MtTranslatorResult { val withReplacedParams = TextHelper.replaceIcuParams(baseTranslationText) val managerResult = - mtServiceManager.translate(getTranslationParams(item, baseTranslationText, withReplacedParams.text)) + context.mtServiceManager.translate( + getTranslationParams( + item = item, + baseTranslationText = baseTranslationText, + withReplacedParams = withReplacedParams.text, + isPlural = isPlural, + ), + ) + if (managerResult.translatedPluralForms != null && isPlural) { + return getPluralResult( + managerResult, + withReplacedParams, + item, + context.getPluralForms(baseTranslationText)?.argName ?: DEFAULT_PLURAL_ARGUMENT_NAME, + ) + } + + return managerResult.getTranslatorResult(withReplacedParams, item) + } + + private fun getPluralResult( + managerResult: TranslateResult, + withReplacedParams: TextHelper.ReplaceIcuResult, + item: MtBatchItemParams, + argName: String, + ): MtTranslatorResult { + val forms = + managerResult.translatedPluralForms + ?: throw IllegalStateException("Plural forms are null") + val translatedText = forms.toIcuPluralString(optimize = true, argName = argName) + return managerResult.getTranslatorResult(withReplacedParams, item).also { + it.translatedText = translatedText + } + } + + private fun TranslateResult.getTranslatorResult( + withReplacedParams: TextHelper.ReplaceIcuResult, + item: MtBatchItemParams, + ): MtTranslatorResult { return MtTranslatorResult( - translatedText = managerResult.translatedText?.replaceParams(withReplacedParams.params), - actualPrice = managerResult.actualPrice, - contextDescription = managerResult.contextDescription, + translatedText = translatedText?.replaceParams(withReplacedParams.params), + actualPrice = actualPrice, + contextDescription = contextDescription, service = item.service, targetLanguageId = item.targetLanguageId, - baseBlank = managerResult.baseBlank, - exception = managerResult.exception, + baseBlank = baseBlank, + exception = exception, ) } @@ -58,18 +155,34 @@ class MtBatchTranslator( item: MtBatchItemParams, baseTranslationText: String, withReplacedParams: String, + isPlural: Boolean, ): TranslationParams { + val targetLanguageTag = + context.languages[item.targetLanguageId]?.tag + ?: throw IllegalStateException("Language ${item.targetLanguageId} not found") + + val pluralForms = if (isPlural) context.getPluralForms(baseTranslationText) else null + val pluralFormsWithReplacedParam = + if (isPlural) context.getPluralFormsReplacingReplaceParam(baseTranslationText) else null + return TranslationParams( text = withReplacedParams, textRaw = baseTranslationText, keyName = context.keys[item.keyId]?.name, - sourceLanguageTag = context.getBaseLanguage().tag, - targetLanguageTag = - context.languages[item.targetLanguageId]?.tag - ?: throw IllegalStateException("Language ${item.targetLanguageId} not found"), + sourceLanguageTag = context.baseLanguage.tag, + targetLanguageTag = targetLanguageTag, serviceInfo = context.getServiceInfo(item.targetLanguageId, item.service), metadata = context.getMetadata(item), isBatch = context.isBatch, + pluralForms = pluralForms?.forms, + pluralFormExamples = + pluralFormsWithReplacedParam?.let { + PluralTranslationUtil.getSourceExamples( + context.baseLanguage.tag, + targetLanguageTag, + it, + ) + }, ) } @@ -80,8 +193,4 @@ class MtBatchTranslator( } return replaced } - - private val mtServiceManager: MtServiceManager by lazy { - applicationContext.getBean(MtServiceManager::class.java) - } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslator.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslator.kt index f51ee70a9a..53f3c8ec2c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslator.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslator.kt @@ -27,7 +27,7 @@ class MtTranslator( publishBeforeEvent(context.project) context.preparePossibleTargetLanguages(paramsList) val batchItems = expandParams(paramsList) - val result = MtBatchTranslator(context, applicationContext).translate(batchItems) + val result = MtBatchTranslator(context).translate(batchItems) publishAfterEvent(context.project, result.sumOf { it.actualPrice }) return result } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslatorContext.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslatorContext.kt index c5961534ed..ca5d129e58 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslatorContext.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/MtTranslatorContext.kt @@ -1,18 +1,21 @@ package io.tolgee.service.machineTranslation +import io.tolgee.component.machineTranslation.MtServiceManager import io.tolgee.component.machineTranslation.metadata.Metadata import io.tolgee.constants.Message import io.tolgee.constants.MtServiceType import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.exceptions.BadRequestException +import io.tolgee.formats.PluralForms import io.tolgee.service.LanguageService +import io.tolgee.service.machineTranslation.PluralTranslationUtil.Companion.REPLACE_NUMBER_PLACEHOLDER import io.tolgee.service.project.ProjectService import jakarta.persistence.EntityManager import org.springframework.context.ApplicationContext class MtTranslatorContext( private val projectId: Long, - private val applicationContext: ApplicationContext, + val applicationContext: ApplicationContext, val isBatch: Boolean, ) { val languages by lazy { @@ -32,21 +35,20 @@ class MtTranslatorContext( /** * LanguageId -> Set of services */ - private val enabledServices = mutableMapOf>() + val enabledServices = mutableMapOf>() /** * LanguageId -> Primary Service */ private val primaryServices = mutableMapOf() - private fun getEnabledServices(languageId: Long): Set { - return enabledServices.computeIfAbsent(languageId) { - val language = - languages[it] - ?: throw IllegalStateException("Language $it not found") - mtServiceConfigService.getEnabledServiceInfos(language).toSet() - } - } + private val pluralFormsCache: MutableMap = mutableMapOf() + + /** + * Whan translating plurals, we need to replace the "REPLACE_NUMBER" (#) ICU tokens with some placeholder + * and we want to cache it + */ + private val pluralFormsWithReplacedReplaceNumberCache: MutableMap = mutableMapOf() fun getServicesToUse( targetLanguageId: Long, @@ -80,6 +82,23 @@ class MtTranslatorContext( .toSet() } + fun preparePossibleTargetLanguages(paramsList: List) { + val all = paramsList.flatMap { it.targetLanguageIds + listOfNotNull(it.targetLanguageId) } + possibleTargetLanguages.addAll(all) + } + + fun getPluralForms(string: String): PluralForms? { + return pluralFormsCache.computeIfAbsentSupportNull(string) { + io.tolgee.formats.getPluralForms(string) + } + } + + fun getPluralFormsReplacingReplaceParam(string: String): PluralForms? { + return pluralFormsWithReplacedReplaceNumberCache.computeIfAbsentSupportNull(string) { + io.tolgee.formats.getPluralFormsReplacingReplaceParam(string, REPLACE_NUMBER_PLACEHOLDER) + } + } + /** * If some primary service is not found, we fetch all missing at once, * so it's fetched in one query, but only when required @@ -94,15 +113,6 @@ class MtTranslatorContext( } } - private fun checkServices( - desired: Set?, - enabled: List, - ) { - if (desired != null && desired.any { !enabled.contains(it) }) { - throw BadRequestException(Message.MT_SERVICE_NOT_ENABLED) - } - } - fun prepareKeys(params: List) { val keyIds = params.mapNotNull { it.keyId }.filter { !keys.containsKey(it) } prepareKeysByIds(keyIds) @@ -112,7 +122,7 @@ class MtTranslatorContext( val result = entityManger.createQuery( """ - select new io.tolgee.service.machineTranslation.KeyForMt(k.id, k.name, ns.name, km.description, t.text) + select new io.tolgee.service.machineTranslation.KeyForMt(k.id, k.name, ns.name, km.description, t.text, k.isPlural ) from Key k left join k.project.baseLanguage bl left join k.translations t on t.language.id = bl.id @@ -135,8 +145,17 @@ class MtTranslatorContext( } } - fun getBaseLanguage(): LanguageDto { - return languages.values.singleOrNull { it.base } ?: throw IllegalStateException("Base language not found") + private fun checkServices( + desired: Set?, + enabled: List, + ) { + if (desired != null && desired.any { !enabled.contains(it) }) { + throw BadRequestException(Message.MT_SERVICE_NOT_ENABLED) + } + } + + val baseLanguage: LanguageDto by lazy { + languages.values.singleOrNull { it.base } ?: throw IllegalStateException("Base language not found") } fun getServiceInfo( @@ -179,9 +198,29 @@ class MtTranslatorContext( return service.serviceType.usesMetadata } - fun preparePossibleTargetLanguages(paramsList: List) { - val all = paramsList.flatMap { it.targetLanguageIds + listOfNotNull(it.targetLanguageId) } - possibleTargetLanguages.addAll(all) + private fun getEnabledServices(languageId: Long): Set { + return enabledServices.computeIfAbsent(languageId) { + val language = + languages[it] + ?: throw IllegalStateException("Language $it not found") + mtServiceConfigService.getEnabledServiceInfos(language).toSet() + } + } + + @Synchronized + private fun MutableMap.computeIfAbsentSupportNull( + key: K, + mappingFunction: (K) -> V, + ): V? { + if (!containsKey(key)) { + val value = mappingFunction(key) + put(key, value) + } + return get(key) + } + + fun getKey(keyId: Long?): KeyForMt? { + return keys[keyId] } private val languageService: LanguageService by lazy { @@ -201,6 +240,10 @@ class MtTranslatorContext( } private val metadataProvider by lazy { - MetadataProvider(this, applicationContext) + MetadataProvider(this) + } + + val mtServiceManager: MtServiceManager by lazy { + applicationContext.getBean(MtServiceManager::class.java) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/PluralTranslationUtil.kt b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/PluralTranslationUtil.kt new file mode 100644 index 0000000000..0eb97f6dd7 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/PluralTranslationUtil.kt @@ -0,0 +1,130 @@ +package io.tolgee.service.machineTranslation + +import com.ibm.icu.text.PluralRules +import io.tolgee.formats.PluralForms +import io.tolgee.formats.getPluralFormExamples +import io.tolgee.formats.getULocaleFromTag +import io.tolgee.formats.toIcuPluralString + +class PluralTranslationUtil( + private val context: MtTranslatorContext, + private val baseTranslationText: String, + private val item: MtBatchItemParams, + private val translateFn: (String) -> MtTranslatorResult, +) { + val forms by lazy { + context.getPluralFormsReplacingReplaceParam(baseTranslationText) + ?: throw IllegalStateException("Plural forms are null") + } + + fun translate(): MtTranslatorResult { + return result + } + + private val preparedFormSourceStrings: Sequence> by lazy { + return@lazy targetExamples.asSequence().map { + val form = sourceRules?.select(it.value.toDouble()) + val formValue = forms.forms[form] ?: forms.forms[PluralRules.KEYWORD_OTHER] ?: "" + it.key to formValue.replaceReplaceNumberPlaceholderWithExample(it.value) + } + } + + private val translated by lazy { + preparedFormSourceStrings.map { + it.first to translateFn(it.second) + } + } + + private val result: MtTranslatorResult by lazy { + val result = + translated.map { (form, result) -> + result.translatedText = result.translatedText?.replaceNumberTags() + form to result + } + + val resultForms = result.map { it.first to (it.second.translatedText ?: "") }.toMap() + + return@lazy MtTranslatorResult( + translatedText = + resultForms.toIcuPluralString( + argName = forms.argName, + optimize = false, + ), + actualPrice = result.sumOf { it.second.actualPrice }, + contextDescription = result.firstOrNull { it.second.contextDescription != null }?.second?.contextDescription, + service = item.service, + targetLanguageId = item.targetLanguageId, + baseBlank = false, + exception = result.firstOrNull { it.second.exception != null }?.second?.exception, + ) + } + + private val targetExamples by lazy { + val targetLanguageTag = context.getLanguage(item.targetLanguageId).tag + val targetULocale = getULocaleFromTag(targetLanguageTag) + val targetRules = PluralRules.forLocale(targetULocale) + getPluralFormExamples(targetRules) + } + + private val sourceRules by lazy { + val sourceLanguageTag = context.baseLanguage.tag + getRulesByTag(sourceLanguageTag) + } + + private fun String.replaceNumberTags(): String { + return this.replace(TOLGEE_TAG_REGEX, "#") + } + + companion object { + const val REPLACE_NUMBER_PLACEHOLDER = "{%{REPLACE_NUMBER}%}" + private const val TOLGEE_TAG_OPEN = "" + private const val TOLGEE_TAG_CLOSE = "" + val TOLGEE_TAG_REGEX = "$TOLGEE_TAG_OPEN.*?$TOLGEE_TAG_CLOSE".toRegex() + + /** + * Returns all target forms with examples from source + */ + fun getSourceExamples( + sourceLanguageTag: String, + targetLanguageTag: String, + pluralForms: PluralForms, + ): Map { + return getSourceExamplesSequence(sourceLanguageTag, targetLanguageTag, pluralForms).toMap() + } + + private fun getSourceExamplesSequence( + sourceLanguageTag: String, + targetLanguageTag: String, + pluralForms: PluralForms, + ): Sequence> { + return getTargetNumberExamples(targetLanguageTag).asSequence().map { + val form = getRulesByTag(sourceLanguageTag)?.select(it.value.toDouble()) + val formValue = pluralForms.forms[form] ?: pluralForms.forms[PluralRules.KEYWORD_OTHER] ?: "" + it.key to formValue.replaceReplaceNumberPlaceholderWithExample(it.value, addTag = false) + } + } + + private fun String.replaceReplaceNumberPlaceholderWithExample( + example: Number, + addTag: Boolean = true, + ): String { + val tagOpenString = if (addTag) TOLGEE_TAG_OPEN else "" + val tagCloseString = if (addTag) TOLGEE_TAG_CLOSE else "" + return this.replace( + REPLACE_NUMBER_PLACEHOLDER, + "$tagOpenString${example}$tagCloseString", + ) + } + + private fun getTargetNumberExamples(targetLanguageTag: String): Map { + val targetULocale = getULocaleFromTag(targetLanguageTag) + val targetRules = PluralRules.forLocale(targetULocale) + return getPluralFormExamples(targetRules) + } + + private fun getRulesByTag(languageTag: String): PluralRules? { + val sourceULocale = getULocaleFromTag(languageTag) + return PluralRules.forLocale(sourceULocale) + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index 83026c2c8c..6e0d994512 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -7,8 +7,8 @@ import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.cacheable.ProjectDto -import io.tolgee.dtos.request.project.CreateProjectDTO -import io.tolgee.dtos.request.project.EditProjectDTO +import io.tolgee.dtos.request.project.CreateProjectRequest +import io.tolgee.dtos.request.project.EditProjectRequest import io.tolgee.dtos.response.ProjectDTO import io.tolgee.dtos.response.ProjectDTO.Companion.fromEntityAndPermission import io.tolgee.exceptions.BadRequestException @@ -143,9 +143,10 @@ class ProjectService( @Transactional @CacheEvict(cacheNames = [Caches.PROJECTS], key = "#result.id") - fun createProject(dto: CreateProjectDTO): Project { + fun createProject(dto: CreateProjectRequest): Project { val project = Project() project.name = dto.name + project.icuPlaceholders = dto.icuPlaceholders project.organizationOwner = organizationService.get(dto.organizationId) @@ -165,13 +166,14 @@ class ProjectService( @CacheEvict(cacheNames = [Caches.PROJECTS], key = "#result.id") fun editProject( id: Long, - dto: EditProjectDTO, + dto: EditProjectRequest, ): Project { val project = projectRepository.findById(id) .orElseThrow { NotFoundException() }!! project.name = dto.name project.description = dto.description + project.icuPlaceholders = dto.icuPlaceholders dto.baseLanguageId?.let { val language = @@ -391,7 +393,7 @@ class ProjectService( } private fun getOrAssignBaseLanguage( - dto: CreateProjectDTO, + dto: CreateProjectRequest, createdLanguages: List, ): Language { if (dto.baseLanguageTag != null) { diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt index 5d621e4054..47d899cf46 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/translationViewBuilder/QueryBase.kt @@ -41,6 +41,8 @@ class QueryBase( val whereConditions: MutableSet = HashSet() val root: Root = query.from(Key::class.java) val keyNameExpression: Path = root.get(Key_.name) + val keyIsPluralExpression: Path = root.get(Key_.isPlural) + val keyArgNameExpression: Path = root.get(Key_.pluralArgName) val keyIdExpression: Path = root.get(Key_.id) val querySelection = QuerySelection() val fullTextFields: MutableSet> = HashSet() @@ -54,6 +56,8 @@ class QueryBase( init { querySelection[KeyWithTranslationsView::keyId.name] = keyIdExpression querySelection[KeyWithTranslationsView::keyName.name] = keyNameExpression + querySelection[KeyWithTranslationsView::keyIsPlural.name] = keyIsPluralExpression + querySelection[KeyWithTranslationsView::keyPluralArgName.name] = keyArgNameExpression whereConditions.add(cb.equal(root.get(Key_.PROJECT).get(Project_.ID), this.projectId)) fullTextFields.add(root.get(Key_.name)) addLeftJoinedColumns() diff --git a/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationMemoryService.kt b/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationMemoryService.kt index 381db02a9c..8a074b0c59 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationMemoryService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationMemoryService.kt @@ -29,11 +29,13 @@ class TranslationMemoryService( fun suggest( baseTranslationText: String, + isPlural: Boolean, targetLanguage: LanguageDto, pageable: Pageable, ): Page { return translationsService.getTranslationMemorySuggestions( baseTranslationText, + isPlural, null, targetLanguage, pageable, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationService.kt index 6cf0306553..fea190ca93 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/translation/TranslationService.kt @@ -8,6 +8,10 @@ import io.tolgee.dtos.request.translation.TranslationFilters import io.tolgee.events.OnTranslationsSet import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException +import io.tolgee.formats.StringIsNotPluralException +import io.tolgee.formats.convertToIcuPlural +import io.tolgee.formats.getPluralForms +import io.tolgee.formats.normalizePlurals import io.tolgee.helpers.TextHelper import io.tolgee.model.ILanguage import io.tolgee.model.Language @@ -26,6 +30,7 @@ import io.tolgee.service.dataImport.ImportService import io.tolgee.service.key.KeyService import io.tolgee.service.project.ProjectService import io.tolgee.service.queryBuilders.translationViewBuilder.TranslationViewDataProvider +import io.tolgee.util.nullIfEmpty import jakarta.persistence.EntityManager import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.ApplicationEventPublisher @@ -34,6 +39,7 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.io.Serializable import java.util.* @Service @@ -153,16 +159,6 @@ class TranslationService( return translationViewDataProvider.getSelectAllKeys(projectId, languages, params) } - fun setTranslation( - key: Key, - languageTag: String?, - text: String?, - ): Translation? { - val language = - languageService.getByTag(languageTag!!, key.project) - return setTranslation(key, entityManager.getReference(Language::class.java, language.id), text) - } - fun setTranslation( key: Key, language: Language, @@ -205,6 +201,8 @@ class TranslationService( key: Key, translations: Map, ): Map { + val normalized = + validateAndNormalizePlurals(translations, key.isPlural, key.pluralArgName) val languages = languageService.findEntitiesByTags(translations.keys, key.project.id) val oldTranslations = getKeyTranslations(languages, key.project, key).associate { @@ -216,7 +214,7 @@ class TranslationService( return setForKey( key, - translations.map { languageByTagFromLanguages(it.key, languages) to it.value } + normalized.map { languageByTagFromLanguages(it.key, languages) to it.value } .toMap(), oldTranslations, ).mapKeys { it.key.tag } @@ -248,7 +246,7 @@ class TranslationService( val result = translations.entries.associate { (language, value) -> language to setTranslation(key, language, value) - }.filterValues { it != null }.mapValues { it.value } + }.mapValues { it.value } applicationEventPublisher.publishEvent( OnTranslationsSet( @@ -328,10 +326,9 @@ class TranslationService( } fun findBaseTranslation(key: Key): Translation? { - projectService.getOrAssignBaseLanguage(key.project.id)?.let { + projectService.getOrAssignBaseLanguage(key.project.id).let { return find(key, it).orElse(null) } - return null } fun getTranslationMemoryValue( @@ -356,11 +353,18 @@ class TranslationService( val baseTranslationText = baseTranslation.text ?: return Page.empty(pageable) - return getTranslationMemorySuggestions(baseTranslationText, key, targetLanguage, pageable) + return getTranslationMemorySuggestions( + baseTranslationText, + isPlural = key.isPlural, + key, + targetLanguage, + pageable, + ) } fun getTranslationMemorySuggestions( sourceTranslationText: String, + isPlural: Boolean, key: Key?, targetLanguage: LanguageDto, pageable: Pageable, @@ -370,6 +374,7 @@ class TranslationService( } return translationRepository.getTranslateMemorySuggestions( baseTranslationText = sourceTranslationText, + isPlural = isPlural, key = key, targetLanguageId = targetLanguage.id, pageable = pageable, @@ -522,4 +527,64 @@ class TranslationService( "language_id IN (SELECT id FROM language WHERE project_id = :projectId)", ).setParameter("projectId", projectId).executeUpdate() } + + fun onKeyIsPluralChanged( + keyIdToArgNameMap: Map, + newIsPlural: Boolean, + ignoreTranslationsForMigration: MutableList = mutableListOf(), + ) { + val translations = + translationRepository + .getAllByKeyIdInExcluding(keyIdToArgNameMap.keys, ignoreTranslationsForMigration.nullIfEmpty()) + translations.forEach { handleIsPluralChanged(it, newIsPlural, keyIdToArgNameMap[it.key.id]) } + saveAll(translations) + } + + private fun handleIsPluralChanged( + it: Translation, + newIsPlural: Boolean, + newPluralArgName: String?, + ) { + it.text = getNewText(it.text, newIsPlural, newPluralArgName) + } + + /** + * @param newIsPlural - if true, we are converting value to plural, + * if not, we are converting it from plural + */ + private fun getNewText( + text: String?, + newIsPlural: Boolean, + newPluralArgName: String?, + ): String? { + if (newIsPlural) { + return text.convertToIcuPlural(newPluralArgName) + } + val forms = getPluralForms(text) + return forms?.forms?.get("other") ?: text + } + + fun validateAndNormalizePlurals( + texts: Map, + isKeyPlural: Boolean, + pluralArgName: String?, + ): Map { + if (isKeyPlural) { + return validateAndNormalizePlurals(texts, pluralArgName) + } + + return texts + } + + fun validateAndNormalizePlurals( + texts: Map, + pluralArgName: String?, + ): Map { + @Suppress("UNCHECKED_CAST") + return try { + normalizePlurals(texts, pluralArgName) + } catch (e: StringIsNotPluralException) { + throw BadRequestException(Message.INVALID_PLURAL_FORM, listOf(e.invalidStrings) as List) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/util/WordCounter.kt b/backend/data/src/main/kotlin/io/tolgee/util/WordCounter.kt index 7d808eba7c..fdd3ce8e70 100644 --- a/backend/data/src/main/kotlin/io/tolgee/util/WordCounter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/util/WordCounter.kt @@ -1,7 +1,7 @@ package io.tolgee.util import com.ibm.icu.text.BreakIterator -import com.ibm.icu.util.ULocale +import io.tolgee.formats.getULocaleFromTag object WordCounter { private val NON_WORD = """[\p{P} \t\n\r~!@#$%^&*()_+{}\[\]:;,.<>/?-]""".toRegex() @@ -11,7 +11,7 @@ object WordCounter { text: String, languageTag: String, ): Int { - val uLocale = getLocaleFromTag(languageTag) + val uLocale = getULocaleFromTag(languageTag) val iterator: BreakIterator = BreakIterator.getWordInstance(uLocale) iterator.setText(text) @@ -28,19 +28,4 @@ object WordCounter { } return words } - - fun getLocaleFromTag(tag: String): ULocale { - var result = ULocale.forLanguageTag(tag) - if (result.language.isNotBlank()) { - return result - } - - val languagePart = tag.replace(LANGUAGE_PART, "$1") - result = ULocale.forLanguageTag(languagePart) - if (result.language.isNotBlank()) { - return result - } - - return ULocale.ENGLISH - } } diff --git a/backend/data/src/main/kotlin/io/tolgee/util/domBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/util/domBuilder.kt new file mode 100644 index 0000000000..53702eb6f5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/util/domBuilder.kt @@ -0,0 +1,82 @@ +package io.tolgee.util + +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.xml.sax.InputSource +import java.io.StringReader +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.Transformer +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +fun buildDom(builder: Document.() -> Unit): DomBuilder { + val documentBuilderFactory = DocumentBuilderFactory.newInstance() + val documentBuilder = documentBuilderFactory.newDocumentBuilder() + val document = documentBuilder.newDocument() + + val domBuilder = DomBuilder(document) + builder(domBuilder.document) + return domBuilder +} + +class DomBuilder(val document: Document) { + fun write(): String { + val transformer: Transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.INDENT, "yes") + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2") + val source = DOMSource(document) + val writer = StringWriter() + val result = StreamResult(writer) + transformer.transform(source, result) + return writer.buffer.toString() + } +} + +inline fun Document.element( + name: String, + builder: Element.() -> Unit = {}, +): Element { + val element = this.createElement(name) + this.appendChild(element) + builder(element) + return element +} + +inline fun Element.element( + name: String, + builder: (Element.() -> Unit) = {}, +): Element { + val element = this.ownerDocument.createElement(name) + this.appendChild(element) + builder.invoke(element) + return element +} + +fun Element.attr( + name: String, + value: String?, +) { + val attr = this.ownerDocument.createAttribute(name) + attr.value = value ?: "" + this.setAttributeNode(attr) +} + +fun Element.appendXmlOrText(content: String?) { + val contentNotNull = content ?: "" + try { + val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val doc: Document = documentBuilder.parse(InputSource(StringReader("$contentNotNull"))) + val childNodes: NodeList = doc.documentElement.childNodes + for (i in 0 until childNodes.length) { + val importedNode: Node = this.ownerDocument.importNode(childNodes.item(i), true) + appendChild(importedNode) + } + } catch (ex: java.lang.Exception) { + textContent = contentNotNull + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/util/filterImportFiles.kt b/backend/data/src/main/kotlin/io/tolgee/util/filterImportFiles.kt new file mode 100644 index 0000000000..7535466cb5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/util/filterImportFiles.kt @@ -0,0 +1,17 @@ +package io.tolgee.util + +val IGNORED_REGEXES by lazy { + arrayOf( + // contents.json from Apple's xcloc + "^[\\w-_]+\\.xcloc/contents.json$".toRegex(), + "^.+\\.xcloc/Source Contents/Localizable.xcstrings$".toRegex(), + "^.+\\.xcloc/Source Contents/.*InfoPlist.xcstrings$".toRegex(), + ) +} + +inline fun filterFiles(files: List>): Collection { + return files + .filter { file -> !IGNORED_REGEXES.any { it.matches(file.first) } } + .map { it.second } + .toList() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/util/nullIfEmpty.kt b/backend/data/src/main/kotlin/io/tolgee/util/nullIfEmpty.kt new file mode 100644 index 0000000000..55f23047e5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/util/nullIfEmpty.kt @@ -0,0 +1,5 @@ +package io.tolgee.util + +fun > T.nullIfEmpty(): T? { + return if (this.none()) null else this +} diff --git a/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt b/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt new file mode 100644 index 0000000000..1d50caf49a --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/util/stringUtil.kt @@ -0,0 +1,4 @@ +package io.tolgee.util + +val String.nullIfEmpty: String? + get() = this.ifEmpty { null } diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 786cc61157..49cf5fb3cb 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3049,4 +3049,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt b/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt new file mode 100644 index 0000000000..c3d0cd72e6 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/service/machineTranslation/MtBatchTranslatorTest.kt @@ -0,0 +1,238 @@ +package io.tolgee.service.machineTranslation + +import io.tolgee.component.machineTranslation.MtServiceManager +import io.tolgee.component.machineTranslation.TranslateResult +import io.tolgee.component.machineTranslation.metadata.ExampleItem +import io.tolgee.constants.MtServiceType +import io.tolgee.dtos.cacheable.LanguageDto +import io.tolgee.dtos.cacheable.ProjectDto +import io.tolgee.model.mtServiceConfig.Formality +import io.tolgee.model.views.TranslationMemoryItemView +import io.tolgee.service.LanguageService +import io.tolgee.service.bigMeta.BigMetaService +import io.tolgee.service.translation.TranslationService +import io.tolgee.testing.assert +import jakarta.persistence.EntityManager +import jakarta.persistence.TypedQuery +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.springframework.context.ApplicationContext +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +class MtBatchTranslatorTest { + private lateinit var preparedKey: KeyForMt + private lateinit var mtServiceManagerResults: List + + @Test + fun `correctly translates plurals for service not supporting plurals`() { + prepareValidKey() + prepareSeparatePluralsResponse() + + val context = getMtTranslatorContext() + val translator = MtBatchTranslator(context) + + val translated = + translator.translate( + listOf( + MtBatchItemParams( + keyId = 1, + baseTranslationText = preparedKey.baseTranslation, + targetLanguageId = 1, + service = MtServiceType.GOOGLE, + ), + ), + ).first() + + translated.translatedText.assert.isEqualTo( + "{value, plural,\n" + + "one {Jeden pes}\n" + + "few {'#' psi}\n" + + "many {'#' psa}\n" + + "other {'#' psů}\n" + + "}", + ) + translated.actualPrice.assert.isEqualTo(400) + } + + @Test + fun `correctly translates plurals for service supporting plurals`() { + prepareValidKey() + prepareSingleValuePluralResponse() + + val context = getMtTranslatorContext() + val translator = MtBatchTranslator(context) + + val translated = + translator.translate( + listOf( + MtBatchItemParams( + keyId = 1, + baseTranslationText = preparedKey.baseTranslation, + targetLanguageId = 1, + service = MtServiceType.TOLGEE, + ), + ), + ).first() + + translated.translatedText.assert.isEqualTo( + "{value, plural,\n" + + "one {Jeden pes}\n" + + "few {'#' psi}\n" + + "many {'#' psa}\n" + + "other {'#' psů}\n" + + "}", + ) + translated.actualPrice.assert.isEqualTo(100) + } + + private fun prepareValidKey() { + preparedKey = + KeyForMt( + id = 1, + name = "key", + namespace = "test", + description = "test", + baseTranslation = "{value, plural, one {# dog} other {# dogs}}", + isPlural = true, + ) + } + + private fun prepareSingleValuePluralResponse() { + mtServiceManagerResults = + listOf( + TranslateResult( + translatedText = null, + translatedPluralForms = + mapOf( + "one" to "Jeden pes", + "few" to "# psi", + "many" to "# psa", + "other" to "# psů", + ), + actualPrice = 100, + usedService = MtServiceType.TOLGEE, + ), + ) + } + + private fun prepareSeparatePluralsResponse() { + mtServiceManagerResults = + listOf( + TranslateResult( + translatedText = "Jeden pes", + actualPrice = 100, + usedService = MtServiceType.GOOGLE, + ), + TranslateResult( + translatedText = "2 psi", + actualPrice = 100, + usedService = MtServiceType.GOOGLE, + ), + TranslateResult( + translatedText = "0,5 psa", + actualPrice = 100, + usedService = MtServiceType.GOOGLE, + ), + TranslateResult( + translatedText = "10 psů", + actualPrice = 100, + usedService = MtServiceType.GOOGLE, + ), + ) + } + + @Suppress("SqlSourceToSinkFlow") + private fun mockApplicationContext(): ApplicationContext { + val applicationContextMock = mock() + val entityManagerMock = mock(EntityManager::class.java) + whenever(applicationContextMock.getBean(EntityManager::class.java)).thenReturn(entityManagerMock) + mockQueryResult(entityManagerMock, mutableListOf(preparedKey)) { + this.contains("new io.tolgee.service.machineTranslation.KeyForMt") + } + val mtServiceManagerMock = mock(MtServiceManager::class.java) + whenever(applicationContextMock.getBean(MtServiceManager::class.java)).thenReturn(mtServiceManagerMock) + val firstResult = mtServiceManagerResults.firstOrNull() + val rest = mtServiceManagerResults.drop(1) + whenever(mtServiceManagerMock.translate(any())).thenReturn(firstResult, *rest.toTypedArray()) + + val languageServiceMock = mock(LanguageService::class.java) + whenever(applicationContextMock.getBean(LanguageService::class.java)).thenReturn(languageServiceMock) + whenever(languageServiceMock.getProjectLanguages(any())).thenReturn( + listOf( + LanguageDto(0, tag = "en", base = true), + LanguageDto(id = 1, tag = "cs"), + ), + ) + + mockQueryResult(entityManagerMock, mutableListOf()) { + this.contains("io.tolgee.component.machineTranslation.metadata.ExampleItem") + } + + val translationServiceMock = mock(TranslationService::class.java) + whenever(applicationContextMock.getBean(TranslationService::class.java)).thenReturn(translationServiceMock) + doAnswer { + Page.empty() + } + .whenever( + translationServiceMock, + ).getTranslationMemorySuggestions( + any(), + any(), + eq(null), + any(), + any(), + ) + val bigMetaServiceMock = mock() + whenever(applicationContextMock.getBean(BigMetaService::class.java)).thenReturn(bigMetaServiceMock) + whenever(bigMetaServiceMock.getCloseKeyIds(any())).thenReturn(emptyList()) + + val projectServiceMock = mock(io.tolgee.service.project.ProjectService::class.java) + whenever(applicationContextMock.getBean(io.tolgee.service.project.ProjectService::class.java)) + .thenReturn(projectServiceMock) + val projectDtoMock = mock(ProjectDto::class.java) + whenever(projectServiceMock.getDto(any())).thenReturn(projectDtoMock) + return applicationContextMock + } + + @Suppress("SqlSourceToSinkFlow") + private fun mockQueryResult( + entityManagerMock: EntityManager, + result: MutableList, + queryMatcher: String.() -> Boolean = { true }, + ) { + val queryMock = mock>() + whenever( + entityManagerMock.createQuery( + argThat { + queryMatcher(this) + }, + any>(), + ), + ).thenReturn(queryMock) + whenever(queryMock.resultList).thenReturn(result) + whenever(queryMock.setParameter(any(), any())).thenReturn(queryMock) + } + + private fun getMtTranslatorContext(): MtTranslatorContext { + val applicationContext = mockApplicationContext() + val context = MtTranslatorContext(0, applicationContext, false) + context.enabledServices[1L] = + setOf( + MtServiceInfo( + MtServiceType.GOOGLE, + formality = Formality.FORMAL, + ), + MtServiceInfo( + MtServiceType.TOLGEE, + formality = Formality.FORMAL, + ), + ) + return context + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/CoreImportFileProcessorUnitTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/CoreImportFileProcessorUnitTest.kt index 4a39d8351a..31e4f4ed86 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/CoreImportFileProcessorUnitTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/CoreImportFileProcessorUnitTest.kt @@ -1,9 +1,13 @@ package io.tolgee.unit +import io.tolgee.component.KeyCustomValuesValidator import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.cacheable.LanguageDto import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.dtos.dataImport.ImportFileDto +import io.tolgee.dtos.request.dataImport.ImportSettingsRequest +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.ImportFileProcessorFactory import io.tolgee.model.Language import io.tolgee.model.Project import io.tolgee.model.UserAccount @@ -17,8 +21,6 @@ import io.tolgee.service.LanguageService import io.tolgee.service.dataImport.CoreImportFilesProcessor import io.tolgee.service.dataImport.ImportService import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.ImportFileProcessor -import io.tolgee.service.dataImport.processors.ProcessorFactory import io.tolgee.service.key.KeyMetaService import io.tolgee.service.translation.TranslationService import org.assertj.core.api.Assertions.assertThat @@ -31,13 +33,12 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.context.ApplicationContext -import java.util.* class CoreImportFileProcessorUnitTest { private lateinit var existingLanguageEntity: Language private lateinit var applicationContextMock: ApplicationContext private lateinit var importMock: Import - private lateinit var processorFactoryMock: ProcessorFactory + private lateinit var importFileProcessorFactoryMock: ImportFileProcessorFactory private lateinit var importServiceMock: ImportService private lateinit var languageServiceMock: LanguageService private lateinit var processor: CoreImportFilesProcessor @@ -51,12 +52,13 @@ class CoreImportFileProcessorUnitTest { private lateinit var authenticationFacadeMock: AuthenticationFacade private lateinit var keyMetaServiceMock: KeyMetaService private lateinit var tolgeePropertiesMock: TolgeeProperties + private lateinit var keyCustomValuesValidatorMock: KeyCustomValuesValidator @BeforeEach fun setup() { applicationContextMock = mock() importMock = mock() - processorFactoryMock = mock() + importFileProcessorFactoryMock = mock() importServiceMock = mock() languageServiceMock = mock() translationServiceMock = mock() @@ -64,25 +66,38 @@ class CoreImportFileProcessorUnitTest { authenticationFacadeMock = mock() keyMetaServiceMock = mock() tolgeePropertiesMock = mock() + keyCustomValuesValidatorMock = mock() importFile = ImportFile("lgn.json", importMock) importFileDto = ImportFileDto("lng.json", "".toByteArray()) existingLanguage = LanguageDto(name = "lng") existingLanguageEntity = Language().apply { name = existingLanguage.name } existingTranslation = Translation("helllo").also { it.key = Key(name = "colliding key") } - processor = CoreImportFilesProcessor(applicationContextMock, importMock) + processor = + CoreImportFilesProcessor( + applicationContextMock, importMock, + importSettings = + ImportSettingsRequest( + overrideKeyDescriptions = false, + convertPlaceholdersToIcu = true, + ), + ) - whenever(applicationContextMock.getBean(ProcessorFactory::class.java)).thenReturn(processorFactoryMock) + whenever(applicationContextMock.getBean(ImportFileProcessorFactory::class.java)).thenReturn( + importFileProcessorFactoryMock, + ) whenever(applicationContextMock.getBean(ImportService::class.java)).thenReturn(importServiceMock) whenever(applicationContextMock.getBean(LanguageService::class.java)).thenReturn(languageServiceMock) whenever(applicationContextMock.getBean(TranslationService::class.java)).thenReturn(translationServiceMock) whenever(applicationContextMock.getBean(AuthenticationFacade::class.java)).thenReturn(authenticationFacadeMock) whenever(applicationContextMock.getBean(KeyMetaService::class.java)).thenReturn(keyMetaServiceMock) whenever(applicationContextMock.getBean(TolgeeProperties::class.java)).thenReturn(tolgeePropertiesMock) + whenever(applicationContextMock.getBean(KeyCustomValuesValidator::class.java)) + .thenReturn(keyCustomValuesValidatorMock) whenever(tolgeePropertiesMock.maxTranslationTextLength).then { 10000L } - whenever(processorFactoryMock.getProcessor(eq(importFileDto), any())).thenReturn(typeProcessorMock) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) + whenever(importFileProcessorFactoryMock.getProcessor(eq(importFileDto), any())).thenReturn(typeProcessorMock) + fileProcessorContext = FileProcessorContext(importFileDto, importFile, applicationContext = applicationContextMock) fileProcessorContext.languages = mutableMapOf("lng" to ImportLanguage("lng", importFile)) whenever(typeProcessorMock.context).then { fileProcessorContext } whenever(importMock.project).thenReturn(Project(1, "test repo")) @@ -138,13 +153,13 @@ class CoreImportFileProcessorUnitTest { fileProcessorContext.addTranslation("test_key", "lng", "value") fileProcessorContext.addKeyCodeReference("test_key", "hello.php", 10) fileProcessorContext.addKeyCodeReference("test_key", "hello2.php", 10) - fileProcessorContext.addKeyComment("test_key", "test comment") + fileProcessorContext.addKeyDescription("test_key", "test comment") whenever(translationServiceMock.getAllByLanguageId(any())).thenReturn(listOf()) processor.processFiles(listOf(importFileDto)) verify(keyMetaServiceMock).save( argThat { - this.comments.any { it.text == "test comment" } + this.description == "test comment" }, ) verify(keyMetaServiceMock).save( @@ -156,7 +171,7 @@ class CoreImportFileProcessorUnitTest { argThat { assertThat(this[0].key.keyMeta).isNotNull assertThat(this[0].key.keyMeta?.codeReferences).hasSize(2) - assertThat(this[0].key.keyMeta?.comments).hasSize(1) + assertThat(this[0].key.keyMeta?.description).isNotBlank() true }, ) diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/CPoConversionTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/CPoConversionTest.kt new file mode 100644 index 0000000000..e11b81c04a --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/CPoConversionTest.kt @@ -0,0 +1,36 @@ +package io.tolgee.unit.formatConversions + +import io.tolgee.formats.po.`in`.messageConvertors.PoCToIcuImportMessageConvertor +import io.tolgee.formats.po.out.c.ToCPoMessageConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class CPoConversionTest { + @Test + fun `it transforms`() { + testString("Hello %s") + testString("Hello %d") + testString("Hello %.2f") + testString("Hello %f") + testString("Hello %e") + testString("Hello %2\$e, hello %1\$s") + testString("Hello %.50f") + testString("Hello %.50f") + } + + @Test + fun `doesn't limit precision`() { + convertToIcu("Hello %.51f") + .assert.isEqualTo("Hello %.51f") + } + + private fun testString(string: String) { + val icuString = convertToIcu(string) + val cString = ToCPoMessageConvertor(icuString!!, forceIsPlural = false).convert().singleResult + cString.assert + .describedAs("Input:\n${string}\nICU:\n$icuString\nC String:\n$cString") + .isEqualTo(string) + } + + private fun convertToIcu(string: String) = PoCToIcuImportMessageConvertor().convert(string, "en").message +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PhpPoConversionTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PhpPoConversionTest.kt new file mode 100644 index 0000000000..4779b279d9 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PhpPoConversionTest.kt @@ -0,0 +1,35 @@ +package io.tolgee.unit.formatConversions + +import io.tolgee.formats.po.`in`.messageConvertors.PoPhpToIcuImportMessageConvertor +import io.tolgee.formats.po.out.php.ToPhpPoMessageConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class PhpPoConversionTest { + @Test + fun `it transforms`() { + testString("Hello %s") + testString("Hello %d") + testString("Hello %.2f") + testString("Hello %f") + testString("Hello %e") + testString("Hello %2\$e, hello %1\$s") + testString("Hello %.50f") + testString("Hello %.50f") + } + + @Test + fun `doesn't limit precision`() { + convertToIcu("Hello %.51f").assert.isEqualTo("Hello %.51f") + } + + private fun testString(string: String) { + val icuString = convertToIcu(string) + val phpString = ToPhpPoMessageConvertor(icuString!!, forceIsPlural = false).convert().singleResult + phpString.assert + .describedAs("Input:\n${string}\nICU:\n$icuString\nPhpString:\n$phpString") + .isEqualTo(string) + } + + private fun convertToIcu(string: String) = PoPhpToIcuImportMessageConvertor().convert(string, "en").message +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PythonPoConversionTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PythonPoConversionTest.kt new file mode 100644 index 0000000000..7e17e887d0 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formatConversions/PythonPoConversionTest.kt @@ -0,0 +1,36 @@ +package io.tolgee.unit.formatConversions + +import io.tolgee.formats.po.`in`.messageConvertors.PoPythonToIcuImportMessageConvertor +import io.tolgee.formats.po.out.python.ToPythonPoMessageConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class PythonPoConversionTest { + @Test + fun `it transforms`() { + testString("Hello %(a)s") + testString("Hello %(a)d") + testString("Hello %(a).2f") + testString("Hello %(a)f") + testString("Hello %(a)e") + testString("Hello %(a)e, hello %(v)s") + testString("Hello %(a).50f") + testString("Hello %(a).50f") + } + + @Test + fun `doesn't limit precision`() { + convertToIcu("Hello %(a).51f") + .assert.isEqualTo("Hello %(a).51f") + } + + private fun testString(string: String) { + val icuString = convertToIcu(string) + val pythonString = ToPythonPoMessageConvertor(icuString!!, forceIsPlural = false).convert().singleResult + pythonString.assert + .describedAs("Input:\n${string}\nICU:\n$icuString\nPython String:\n$pythonString") + .isEqualTo(string) + } + + private fun convertToIcu(string: String) = PoPythonToIcuImportMessageConvertor().convert(string, "en").message +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/BaseIcuImportMessageConvertorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/BaseIcuImportMessageConvertorTest.kt new file mode 100644 index 0000000000..ef4e2266d3 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/BaseIcuImportMessageConvertorTest.kt @@ -0,0 +1,43 @@ +package io.tolgee.unit.formats + +import io.tolgee.formats.BaseIcuMessageConvertor +import io.tolgee.formats.NoOpFromIcuParamConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class BaseIcuImportMessageConvertorTest { + @Test + fun `converts plural`() { + val forms = + BaseIcuMessageConvertor( + "Hello! I have {count, plural, other {# dogs} one {# dog} many {# dogs}}. " + + "Did you know? Here is a number {num, number}", + NoOpFromIcuParamConvertor(), + ).convert().formsResult!! + forms["one"].assert.isEqualTo("Hello! I have # dog. Did you know? Here is a number {num, number}") + forms["many"].assert.isEqualTo("Hello! I have # dogs. Did you know? Here is a number {num, number}") + forms["other"].assert.isEqualTo("Hello! I have # dogs. Did you know? Here is a number {num, number}") + forms.keys.size.assert.isEqualTo(3) + } + + @Test + fun `works with forced isPlural = true`() { + val forms = + BaseIcuMessageConvertor( + "Hello!", + NoOpFromIcuParamConvertor(), + forceIsPlural = true, + ).convert().formsResult!! + forms["other"].assert.isEqualTo("Hello!") + forms.keys.size.assert.isEqualTo(1) + } + + @Test + fun `works with forced isPlural = false`() { + BaseIcuMessageConvertor( + "{num, plural, one {# dog} other {# dogs}}", + NoOpFromIcuParamConvertor(), + forceIsPlural = false, + ).convert().singleResult.assert.isEqualTo("{num, plural, one {# dog} other {# dogs}}") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/LocaleUtilTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/LocaleUtilTest.kt new file mode 100644 index 0000000000..ae0122989f --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/LocaleUtilTest.kt @@ -0,0 +1,15 @@ +package io.tolgee.unit.formats + +import io.tolgee.formats.getULocaleFromTag +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class LocaleUtilTest { + @Test + fun `return correct ULocale from tag`() { + getULocaleFromTag("cs").toLanguageTag().assert.isEqualTo("cs") + getULocaleFromTag("ar-bla").toLanguageTag().assert.isEqualTo("ar") + getULocaleFromTag("en-US").toLanguageTag().assert.isEqualTo("en-US") + getULocaleFromTag("hy-Latn-IT-arevela").toLanguageTag().assert.isEqualTo("hy-Latn-IT-arevela") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/MessagePatternUtilTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/MessagePatternUtilTest.kt new file mode 100644 index 0000000000..da979a89c4 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/MessagePatternUtilTest.kt @@ -0,0 +1,245 @@ +package io.tolgee.unit.formats + +import io.tolgee.formats.MessagePatternUtil +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test +import kotlin.time.measureTime + +class MessagePatternUtilTest { + @Test + fun `returns correct pattern strings for root node`() { + val full = "{hello, plural, other {# dogs} one {one dog}}" + MessagePatternUtil.buildMessageNode(full) + .assertPatternString(full) + } + + @Test + fun `returns correct pattern strings simple arg node`() { + val root = MessagePatternUtil.buildMessageNode("{hello}") + root.contents.single().patternString.assert.isEqualTo("{hello}") + } + + @Test + fun `returns correct pattern strings with arg node wrapped with text`() { + val root = MessagePatternUtil.buildMessageNode("Hello, {hello}!") + root.contents[1].patternString.assert.isEqualTo("{hello}") + } + + @Test + fun `returns correct pattern strings with arg node with type`() { + val root = MessagePatternUtil.buildMessageNode("Hello, {hello, number}!") + root.contents[1].patternString.assert.isEqualTo("{hello, number}") + } + + @Test + fun `returns correct pattern strings with arg node with style`() { + MessagePatternUtil.buildMessageNode("Hello, {hello, number, scientific}!") + .contents[1].patternString.assert.isEqualTo("{hello, number, scientific}") + MessagePatternUtil.buildMessageNode("Hello, {hello, number, 0.00}!") + .contents[1].patternString.assert.isEqualTo("{hello, number, 0.00}") + } + + @Test + fun `returns correct pattern string for plural forms`() { + val argNode = + MessagePatternUtil.buildMessageNode( + "Hello, {dogsCount, plural, one {I have one dog} other {I have # dogs}}!", + ).contents[1] as MessagePatternUtil.ArgNode + + argNode.complexStyle!!.variants.let { + val one = it[0] + one.patternString.assert.isEqualTo("I have one dog") + one.message?.contents!![0].patternString.assert.isEqualTo("I have one dog") + val other = it[1] + other.patternString.assert.isEqualTo("I have # dogs") + } + } + + @Test + fun `returns correct pattern string for select`() { + val argNode = + MessagePatternUtil.buildMessageNode( + "Hello, {gender, select, man {I am a man!} woman {I am a woman!} other {}}!", + ).contents[1] as MessagePatternUtil.ArgNode + + argNode.complexStyle!!.variants.let { + val man = it[0] + man.patternString.assert.isEqualTo("I am a man!") + val woman = it[1] + woman.patternString.assert.isEqualTo("I am a woman!") + } + } + + @Test + fun `returns correct pattern string for choice`() { + val argNode = + MessagePatternUtil.buildMessageNode( + "Hello, {count, choice, 0#There are no dogs|1#There is one dog|1 { + listOf("a.a", "a").testResult('.', true) + }.message.assert.contains("Path: a") + } + + @Test + fun `it handles collision - throws when not sorted - array with text`() { + assertThrows { + listOf("a[10]", "a").testResult('.', true) + }.message.assert.contains("Path: a") + } + + @Test + fun `adds multiple items to root array`() { + listOf("[10]", "[11]").testResult('.', true) { + isArray.hasSize(2) + } + } + + @Test + fun `adds multiple items to nested array`() { + listOf("a[10]", "a[11]").testResult('.', true) { + node("a").isArray.hasSize(2) + } + } + + @Test + fun `it works with complex case`() { + listOf( + "[10]", + "a", + "a.a", + "a.a.a", + "a.a.b", + "[11]", + "a[10]", + "a[11]", + "b.a", + "b.c", + "b[3]", + "d[10].a.b[10].a.c", + "e[1]", + "e[2]", + ).sortedBy { it }.testResult('.', true) { + isObject.containsKeys("[10]", "[11]", "a", "a.a", "a.a.a", "a.a.b", "a[10]", "a[11]") + node("b") { + isObject.containsKeys("a", "c", "[3]") + } + node("d[0].a.b[0].a.c").isEqualTo("text") + node("e") { + isArray.hasSize(2) + } + } + } + + @Test + fun `handles collision - root and nested array`() { + listOf("[10].a.b", "[10].a[10]").testResult('.', true) { + node("[0].a").isObject.containsKeys("b", "[10]") + } + } + + private fun List.testResult( + delimiter: Char, + supportArrays: Boolean, + assertFn: JsonAssert.ConfigurableJsonAssert.() -> Unit = {}, + ): JsonAssert.ConfigurableJsonAssert { + val builder = StructureModelBuilder(delimiter, supportArrays) + this.forEach { + builder.addValue(it, "text") + } + val result = builder.result + val jsonString = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result) + val assert = + assertThatJson( + jsonString, + ) + try { + assertFn(assert) + } catch (e: AssertionError) { + println(jsonString) + throw e + } + return assert + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/in/AndroidXmlFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/in/AndroidXmlFormatProcessorTest.kt new file mode 100644 index 0000000000..62df99dd74 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/in/AndroidXmlFormatProcessorTest.kt @@ -0,0 +1,188 @@ +package io.tolgee.unit.formats.android.`in` + +import io.tolgee.formats.android.`in`.AndroidStringsXmlProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AndroidXmlFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("values-en/strings.xml", "src/test/resources/import/android/strings.xml") + } + + @Test + fun `returns correct parsed result`() { + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "app_name") + .assertSingle { + hasText("Tolgee test") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "dogs_count") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {# dog} + other {# dogs} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[0]") + .assertSingle { + hasText("First item") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[1]") + .assertSingle { + hasText("Second item") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "with_spaces") + .assertSingle { + hasText("Hello!") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "with_html") + .assertSingle { + hasText("Hello!") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "with_xliff_gs") + .assertSingle { + hasText( + "Hello!\n" + + " {0, number}\n" + + " \n" + + " Dont'translate this", + ) + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "with_params") + .assertSingle { + hasText("{0, number} {3} {2, number, .00} {3, number, scientific} %+d") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "dogs_count") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {%d dog %s '{'escape'}'} + other {%d dogs %s} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[0]") + .assertSingle { + hasText("First item %d {escape}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "with_params") + .assertSingle { + hasText("%d %4${'$'}s %.2f %e %+d {escape}") + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "dogs_count") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {%d dog %s '{'escape'}'} + other {%d dogs %s} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[0]") + .assertSingle { + hasText("First item %d '{'escape'}'") + } + mockUtil.fileProcessorContext.assertTranslations("en", "with_params") + .assertSingle { + hasText("%d %4${'$'}s %.2f %e %+d '{'escape'}'") + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "dogs_count") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {# dog {1} '{'escape'}'} + other {# dogs {1}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[0]") + .assertSingle { + hasText("First item {0, number} '{'escape'}'") + } + mockUtil.fileProcessorContext.assertTranslations("en", "string_array[1]") + .assertSingle { + hasText("Second item {0, number}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "with_params") + .assertSingle { + hasText("{0, number} {3} {2, number, .00} {3, number, scientific} %+d '{'escape'}'") + } + mockUtil.fileProcessorContext.assertKey("dogs_count") { + custom.assert.isNull() + description.assert.isNull() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "values-en/strings.xml", + "src/test/resources/import/android/strings_params_everywhere.xml", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + AndroidStringsXmlProcessor(mockUtil.fileProcessorContext).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/out/AdnroidXmlFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/out/AdnroidXmlFileExporterTest.kt new file mode 100644 index 0000000000..a1731e2b46 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/android/out/AdnroidXmlFileExporterTest.kt @@ -0,0 +1,258 @@ +package io.tolgee.unit.formats.android.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.android.out.AndroidStringsXmlExporter +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.testing.assert +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class AdnroidXmlFileExporterTest { + @Test + fun exports() { + val exporter = getExporter() + val data = getExported(exporter) + // generate this with: + // data.map { "data.assertFile(\"${it.key}\", \"\"\"\n |${it.value.replace("\$", "\${'$'}").replace("\n", "\n |")}\n \"\"\".trimMargin())" }.joinToString("\n") + data.assertFile( + "values-cs/strings.xml", + """ + | + | + | Ahoj! I%d, %s, %e, %f + | + | + | + | + | + | + | + | %d den + | %d dny + | %d dní + | %d dní + | + | {count, plural, one {# den} few {# dny} other {# dní}} + | OK! + | I have exact key name + | + | I will be first + | I will be second + | + | + | + """.trimMargin(), + ) + data.assertFile( + "values-en/strings.xml", + """ + | + | + | This is english! + | + | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "values-cs/strings.xml", + """ + | + | + | + | %d den %s + | %d dny + | %d dní + | %d dní + | + | + | I will be first {icuParam} + | + | + | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "values-cs/strings.xml", + """ + | + | + | + | # den {icuParam} + | # dny + | # dní + | # dní + | + | + | I will be first {icuParam} + | + | + | + """.trimMargin(), + ) + } + + private fun getExported(exporter: AndroidStringsXmlExporter): Map { + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + return data + } + + private fun Map.assertFile( + file: String, + content: String, + ) { + this[file]!!.assert.isEqualTo(content) + } + + private fun getExporter(): AndroidStringsXmlExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key1", + text = + "Ahoj! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + ) + add( + languageTag = "cs", + keyName = "Empty plural", + text = null, + ) { + key.isPlural = true + } + + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "forced_not plural", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + ) { + key.isPlural = false + } + + add( + languageTag = "cs", + keyName = "key!with_unsupported!characters", + text = "OK!", + ) + + add( + languageTag = "cs", + // this key will be replaced by key, which has exact key name + keyName = "unsupported_key_will_be!replaced", + text = "I will be missing!", + ) + + add( + languageTag = "cs", + keyName = "unsupported_key_will_be_replaced", + text = "I have exact key name", + ) + + add( + languageTag = "cs", + keyName = "unsupported_key_will_be~replaced", + text = "I will be missing too replace it!", + ) + + add( + languageTag = "cs", + keyName = "i_am_array_item[20]", + text = "I will be first", + ) + + add( + languageTag = "cs", + keyName = "i_am_array_item[100]", + text = "I will be second", + ) + + add( + languageTag = "cs", + keyName = "i_am_array!item[106]", + text = "I won't be added", + ) + + add( + languageTag = "cs", + keyName = "i_am_array~item[106]", + text = "I won't be added", + ) + add( + languageTag = "en", + keyName = "i_am_array_english", + text = "This is english!", + ) + } + return getExporter(built.translations) + } + + private fun getIcuPlaceholdersEnabledExporter(): AndroidStringsXmlExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "i_am_array_item[20]", + text = "I will be first '{'icuParam'}'", + ) + } + return getExporter(built.translations, true) + } + + private fun getIcuPlaceholdersDisabledExporter(): AndroidStringsXmlExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "i_am_array_item[20]", + text = "I will be first {icuParam}", + ) + } + return getExporter(built.translations, false) + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): AndroidStringsXmlExporter { + return AndroidStringsXmlExporter( + translations = translations, + exportParams = ExportParams(), + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/AppleXliffFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/AppleXliffFormatProcessorTest.kt new file mode 100644 index 0000000000..4ec6fb8bcf --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/AppleXliffFormatProcessorTest.kt @@ -0,0 +1,363 @@ +package io.tolgee.unit.formats.apple.`in` + +import io.tolgee.formats.apple.`in`.xliff.AppleXliffFileProcessor +import io.tolgee.formats.xliff.`in`.parser.XliffParser +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertAllSame +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.customEquals +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLInputFactory + +class AppleXliffFormatProcessorTest { + private val xmlEventReader: XMLEventReader + get() { + val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() + return inputFactory.createXMLEventReader(mockUtil.importFileDto.data.inputStream()) + } + + private val parsed get() = XliffParser(xmlEventReader).parse() + + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + @Test + fun `returns correct parsed result`() { + mockFile("cs", "cs.xliff") + processFile() + mockUtil.fileProcessorContext + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "Dogs %lld") + .assertSinglePlural { + hasText( + """ + {0, plural, + zero {No dogs here!} + one {One dog is here!} + other {# dogs here} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "Order %lld") + .assertSinglePlural { + hasText( + """ + {0, plural, + zero {Order # Ticket} + one {Order # Ticket} + other {Order # Tickets} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello!") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("Ahoj!") + } + mockUtil.fileProcessorContext.assertTranslations("en", "label") + .assertSingle { + hasText("label") + } + mockUtil.fileProcessorContext.assertTranslations("en", "CFBundleName") + .assertSingle { + hasText("Localization test") + } + mockUtil.fileProcessorContext.assertTranslations("en", "menu") + .assertSingle { + hasText("menu") + } + mockUtil.fileProcessorContext.assertKey("Dogs %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/en.lproj/Localizable.stringsdict", + "_appleXliffPropertyName" : "dog", + "_appleXliffStringsFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("The count of dogs in the app") + } + mockUtil.fileProcessorContext.assertKey("Order %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/en.lproj/Localizable.stringsdict", + "_appleXliffPropertyName" : "Ticket", + "_appleXliffStringsFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("No comment provided by engineer.") + } + mockUtil.fileProcessorContext.assertKey("key") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("Localizable.strings\n Localization test\n Created by Jan Cizmar on 06.02.2024.") + } + mockUtil.fileProcessorContext.assertKey("label") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("This is just random label") + } + mockUtil.fileProcessorContext.assertKey("CFBundleName") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/Localization test-InfoPlist.xcstrings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("Bundle name") + } + mockUtil.fileProcessorContext.assertKey("menu") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/Menu.xcstrings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + } + + @Test + fun `correctly parses xcstrings xliff`() { + mockFile("en", "en_xcstrings.xliff") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "CFBundleName") + .assertAllSame { + hasText("apple-xliff-localization-test") + } + mockUtil.fileProcessorContext.assertTranslations("en", "standard_key") + .assertAllSame { + hasText("I am normal key!") + } + mockUtil.fileProcessorContext.assertTranslations("en", "dogs_cout_%lld") + .assertAllSame { + hasText("{0, plural,\none {One dog}\nother {# dogs}\n}") + } + mockUtil.fileProcessorContext.assertKey("CFBundleName") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "apple-xliff-localization-test-InfoPlist.xcstrings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("Bundle name") + } + mockUtil.fileProcessorContext.assertKey("standard_key") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localizable.xcstrings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + mockUtil.fileProcessorContext.assertKey("dogs_cout_%lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localizable.xcstrings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "Dogs %lld") + .assertSinglePlural { + hasText( + """ + {0, plural, + zero {No dogs here %@ '{'icuParam'}'!} + one {One dog is here %@ '{'icuParam'}'!} + other {%lld dogs here %@ '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "Hi %lld") + .assertSingle { + hasText("Hi %lld {icuParam}") + } + mockUtil.fileProcessorContext.assertKey("Dogs %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/en.lproj/Localizable.stringsdict", + "_appleXliffPropertyName" : "dog", + "_appleXliffStringsFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("The count of dogs in the app") + } + mockUtil.fileProcessorContext.assertKey("Hi %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "Dogs %lld") + .assertSinglePlural { + hasText( + """ + {0, plural, + zero {No dogs here %@ '{'icuParam'}'!} + one {One dog is here %@ '{'icuParam'}'!} + other {%lld dogs here %@ '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "Hi %lld") + .assertSingle { + hasText("Hi %lld '{'icuParam'}'") + } + mockUtil.fileProcessorContext.assertKey("Dogs %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/en.lproj/Localizable.stringsdict", + "_appleXliffPropertyName" : "dog", + "_appleXliffStringsFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("The count of dogs in the app") + } + mockUtil.fileProcessorContext.assertKey("Hi %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "Dogs %lld") + .assertSinglePlural { + hasText( + """ + {0, plural, + zero {No dogs here {0} '{'icuParam'}'!} + one {One dog is here {0} '{'icuParam'}'!} + other {# dogs here {1} '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "Hi %lld") + .assertSingle { + hasText("Hi {0, number} '{'icuParam'}'") + } + mockUtil.fileProcessorContext.assertKey("Dogs %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "Localization test/en.lproj/Localizable.stringsdict", + "_appleXliffPropertyName" : "dog", + "_appleXliffStringsFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isEqualTo("The count of dogs in the app") + } + mockUtil.fileProcessorContext.assertKey("Hi %lld") { + customEquals( + """ + { + "_appleXliffFileOriginal" : "en.lproj/Localizable.strings" + } + """.trimIndent(), + ) + description.assert.isNull() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "cs.xliff", + "src/test/resources/import/apple/params_everywhere_cs.xliff", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun mockFile( + languageTag: String, + fileName: String = "cs.xliff", + ) { + mockUtil.mockIt("$languageTag.xliff", "src/test/resources/import/apple/$fileName") + } + + private fun processFile() { + AppleXliffFileProcessor(mockUtil.fileProcessorContext, parsed).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsFormatProcessorTest.kt new file mode 100644 index 0000000000..ee441632f0 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsFormatProcessorTest.kt @@ -0,0 +1,114 @@ +package io.tolgee.unit.formats.apple.`in` + +import io.tolgee.formats.apple.`in`.strings.StringsFileProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertTranslations +import io.tolgee.util.description +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class StringsFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("Localizable.strings", "src/test/resources/import/apple/Localizable.strings") + } + + @Test + fun `returns correct parsed result`() { + processFile() + Assertions.assertThat(mockUtil.fileProcessorContext.languages).hasSize(1) + Assertions.assertThat(mockUtil.fileProcessorContext.translations).hasSize(7) + assertParsed("""welcome_header""", """Hello, {0}""") + assertParsed("""welcome_sub_header""", """Hello, %s""") + assertParsed("""another key""", """Dies ist ein weiterer Schlüssel.""") + assertParsed("""another key " with escaping""", """Dies ist ein weiterer " Schlüssel.""") + assertParsed("""another key \ with escaping 2""", """Dies ist ein weiterer \ Schlüssel.""") + assertParsed("""another key with escaping 3\""", """Dies ist ein weiterer Schlüssel\""") + assertParsed("another key\n\n multiline", "Dies ist ein weiterer\n\nSchlüssel.") + + assertKeyDescription( + "welcome_sub_header", + "Welcome header comment" + + "\nit's a multiline comment\n*/\n\nI cannot trick you!", + ) + + assertKeyDescription("another key", null) + assertKeyDescription("another key \" with escaping", null) + assertKeyDescription("another key \\ with escaping 2", null) + assertKeyDescription( + "another key\n\n multiline", + null, + ) + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "welcome_header") + .assertSingle { + hasText("Hello, %@ {meto}") + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "welcome_header") + .assertSingle { + hasText("Hello, %@ '{'meto'}'") + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "welcome_header") + .assertSingle { + hasText("Hello, {0} '{'meto'}'") + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "values-en/Localizable.string", + "src/test/resources/import/apple/Localizable_params.strings", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + StringsFileProcessor(mockUtil.fileProcessorContext).process() + } + + private fun assertParsed( + key: String, + translationText: String, + ) { + mockUtil.fileProcessorContext.translations[key]!!.single().text.assert.isEqualTo(translationText) + } + + private fun assertKeyDescription( + keyName: String, + expectedDescription: String?, + ) { + val actualDescription = mockUtil.fileProcessorContext.keys[keyName]?.keyMeta?.description + actualDescription.assert.isEqualTo(expectedDescription) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsdictFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsdictFormatProcessorTest.kt new file mode 100644 index 0000000000..43a4254176 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/in/StringsdictFormatProcessorTest.kt @@ -0,0 +1,121 @@ +package io.tolgee.unit.formats.apple.`in` + +import StringsdictFileProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class StringsdictFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.stringsdict", "src/test/resources/import/apple/example.stringsdict") + } + + @Test + fun `returns correct parsed result`() { + processFile() + Assertions.assertThat(mockUtil.fileProcessorContext.languages).hasSize(1) + Assertions.assertThat(mockUtil.fileProcessorContext.translations).hasSize(2) + mockUtil.fileProcessorContext.translations["what-a-key-plural"]!![0].text.assert.isEqualTo( + "{0, plural,\n" + + "one {Peter has # dog}\n" + + "other {Peter hase # dogs}\n" + + "}", + ) + mockUtil.fileProcessorContext.translations["what-a-key-plural-2"]!![0].text.assert.isEqualTo( + "{0, plural,\n" + + "one {Lucy has %la '{'dog'}'}\n" + + "other {Lucy has %la '{'dogs'}'}\n" + + "}", + ) + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "what-a-key-plural") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Peter has %lld dog '{'meto'}'} + other {Peter hase %lld dogs '{'meto'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "what-a-key-plural") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Peter has %lld dog '{'meto'}'} + other {Peter hase %lld dogs '{'meto'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("unknown", "what-a-key-plural") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Peter has # dog '{'meto'}'} + other {Peter hase # dogs '{'meto'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("what-a-key-plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + private fun processFile() { + StringsdictFileProcessor(mockUtil.fileProcessorContext).process() + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "values-en/Localizable.stringsdict", + "src/test/resources/import/apple/Localizable_params.stringsdict", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt new file mode 100644 index 0000000000..d376f71475 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleXliffFileExporterTest.kt @@ -0,0 +1,415 @@ +package io.tolgee.unit.formats.apple.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.apple.APPLE_FILE_ORIGINAL_CUSTOM_KEY +import io.tolgee.formats.apple.out.AppleXliffExporter +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.testing.assert +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class AppleXliffFileExporterTest { + @Test + fun exports() { + val exporter = getExporter() + + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + + // generate this with: + // data.map { "data.assertFile(\"${it.key}\", \"\"\"\n |${it.value.replace("\$", "\${'$'}").replace("\n", "\n |")}\n \"\"\".trimMargin())" }.joinToString("\n") + data.assertFile( + "cs.xlf", + """ + | + | + | + |
+ | + |
+ | + | + | Hello! I%lld, %@, %e, %f + | Ahoj! I%lld, %@, %e, %f + | + | + | + | I have no base + | + | + | I have no target + | + | + | Namespaced + | Namespaced + | + | + |
+ | + |
+ | + |
+ | + | + | Namespaced + | Namespaced + | + | + |
+ | + |
+ | + |
+ | + | + | %#@property@ + | %#@property@ + | + | + | %lld day + | %lld den + | + | + | %lld days + | %lld dny + | + | + | %lld days + | %lld dní + | + | + | %lld days + | %lld dní + | + | + |
+ | + |
+ | + |
+ | + | + | %#@property@ + | %#@property@ + | + | + | %lld day + | %lld den + | + | + | %lld days + | %lld dnů + | + | + | %lld days + | %lld dnů + | + | + | %lld days + | %lld dnů + | + | + | %#@property@ + | %#@property@ + | + | + | %lld day + | %lld den + | + | + | %lld days + | dny + | + | + | %lld days + | %lld dnů + | + | + | %lld days + | %lld dnů + | + | + |
+ | + |
+ | + |
+ | + | + | %lld day + | %lld day + | + | + | %lld days + | %lld days + | + | + | %lld days + | %lld days + | + | + | %lld days + | %lld days + | + | + |
+ |
+ | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.xlf", + """ + | + | + | + |
+ | + |
+ | + | + | + | %#@property@ + | + | + | + | %lld den %@ + | + | + | + | %lld dny + | + | + | + | %lld dní + | + | + | + | %lld dní + | + | + |
+ | + |
+ | + |
+ | + | + | + | I will be first {icuParam} + | + | + |
+ |
+ | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.xlf", + """ + | + | + | + |
+ | + |
+ | + | + | + | %#@property@ + | + | + | + | # den {icuParam} + | + | + | + | # dny + | + | + | + | # dní + | + | + | + | # dní + | + | + |
+ | + |
+ | + |
+ | + | + | + | I will be first {icuParam} + | + | + |
+ |
+ | + """.trimMargin(), + ) + } + + private fun getExported(exporter: AppleXliffExporter): Map { + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + return data + } + + private fun Map.assertFile( + file: String, + content: String, + ) { + this[file]!!.assert.isEqualTo(content) + } + + private fun getExporter(): AppleXliffExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key1", + text = + "Ahoj! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + baseText = + "Hello! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + ) + add( + languageTag = "cs", + keyName = "No base!", + text = "I have no base", + ) + add( + languageTag = "cs", + keyName = "No target!", + text = null, + baseText = "I have no target", + ) + add( + languageTag = "cs", + keyName = "key2", + text = "Namespaced", + baseText = "Namespaced", + ) { + key.namespace = "homepage" + } + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + baseText = "{count, plural, one {# day} other {# days}}", + ) { + key.namespace = "homepage" + key.description = "This is a description\n With some spaces \n\n to preserve." + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "key4", + text = "Namespaced", + baseText = "Namespaced", + ) { + key.namespace = "homepage" + key.custom = mapOf(APPLE_FILE_ORIGINAL_CUSTOM_KEY to "Localizable.strings") + } + add( + languageTag = "cs", + keyName = "key5", + text = "{count, plural, one {# den} other {# dnů}}", + baseText = "{count, plural, one {# day} other {# days}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "key ", + text = "{count, plural, one {# day} other {# days}}", + baseText = "{count, plural, one {# day} other {# days}}", + ) { + key.isPlural = true + key.custom = mapOf(APPLE_FILE_ORIGINAL_CUSTOM_KEY to "Localizable.xcstrings") + } + add( + languageTag = "cs", + keyName = "key6", + text = "{count, plural, one {# den} few {dny} other {# dnů}}", + baseText = "{count, plural, one {# day} other {# days}}", + ) { + key.isPlural = true + key.custom = mapOf(APPLE_FILE_ORIGINAL_CUSTOM_KEY to "Localizable.stringsdict") + } + } + return getExporter(built.translations, built.baseTranslations) + } + + private fun getIcuPlaceholdersEnabledExporter(): AppleXliffExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}'", + ) + } + return getExporter(built.translations, emptyList(), true) + } + + private fun getIcuPlaceholdersDisabledExporter(): AppleXliffExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam}", + ) + } + return getExporter(built.translations, emptyList(), false) + } + + private fun getExporter( + translations: List, + baseTranslations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): AppleXliffExporter { + return AppleXliffExporter( + translations = translations, + exportParams = ExportParams(), + baseTranslationsProvider = { baseTranslations }, + baseLanguageTag = "tag", + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/IcuToAppleImportMessageConvertorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/IcuToAppleImportMessageConvertorTest.kt new file mode 100644 index 0000000000..58f77e69b5 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/IcuToAppleImportMessageConvertorTest.kt @@ -0,0 +1,51 @@ +package io.tolgee.unit.formats.apple.out + +import io.tolgee.formats.PossiblePluralConversionResult +import io.tolgee.formats.apple.out.IcuToAppleMessageConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class IcuToAppleImportMessageConvertorTest { + @Test + fun `converts # to li when plural`() { + val result = "{param, plural, other {# dogs}}".getConversionResult() + result.formsResult!!["other"]!!.assert.isEqualTo("%lld dogs") + } + + private fun String.getConversionResult(): PossiblePluralConversionResult { + val result = IcuToAppleMessageConvertor(this, null).convert() + return result + } + + @Test + fun `converts param to @`() { + "hello {name}".assertSingleConverted("hello %@") + } + + @Test + fun `converts number to lld`() { + "hello {name, number}".assertSingleConverted("hello %lld") + } + + @Test + fun `converts float to float`() { + "hello {name, number, 0.00}".assertSingleConverted("hello %.2f") + } + + @Test + fun `numbers correctly`() { + "hello {2, number, 0.00} {1, number}".assertSingleConverted("hello %3$.2f %2${'$'}lld") + } + + @Test + fun `numbers correctly in plurals`() { + val forms = "{number, plural, other {# {2} {1}} one {{1} # {2}}}".getConversionResult().formsResult!! + forms["other"].assert.isEqualTo("%lld %3${'$'}@ %2${'$'}@") + forms["one"].assert.isEqualTo("%2${'$'}@ %lld %3${'$'}@") + } + + private fun String.assertSingleConverted(expected: String) { + val result = IcuToAppleMessageConvertor(this, null).convert() + result.singleResult.assert.isEqualTo(expected) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/StringsStringsdictFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/StringsStringsdictFileExporterTest.kt new file mode 100644 index 0000000000..51439343e7 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/StringsStringsdictFileExporterTest.kt @@ -0,0 +1,312 @@ +package io.tolgee.unit.formats.apple.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.apple.out.AppleStringsStringsdictExporter +import io.tolgee.model.enums.TranslationState +import io.tolgee.service.export.dataProvider.ExportKeyView +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class StringsStringsdictFileExporterTest { + @Test + fun `exports`() { + val exporter = getExporter() + + val data = getExported(exporter) + + // generate this with: + // data.map { "data.assertFile(\"${it.key}\", \"\"\"\n |${it.value.replace("\$", "\${'$'}").replace("\n", "\n |")}\n \"\"\".trimMargin())" }.joinToString("\n") + data.assertFile( + "en.lproj/Localizable.strings", + """ + |"key" = "Hello! I am great today! There you have some params %lld, %@, %e, %f"; + | + | + """.trimMargin(), + ) + data.assertFile( + "en.lproj/Localizable.stringsdict", + """ + | + | + | + | + | + | key + | + | NSStringLocalizedFormatKey + | %#${'$'}{#@format@} + | format + | + | one + | %lld day + | other + | %lld days + | + | + | + | + | + """.trimMargin(), + ) + data.assertFile( + "homepage/en.lproj/Localizable.strings", + """ + |"key" = "Namespaced"; + | + | + """.trimMargin(), + ) + data.assertFile( + "homepage/en.lproj/Localizable.stringsdict", + """ + | + | + | + | + | + | key + | + | NSStringLocalizedFormatKey + | %#${'$'}{#@format@} + | format + | + | one + | %lld day + | other + | %lld days + | + | + | + | + | + """.trimMargin(), + ) + data.assertFile( + "homepage/cs.lproj/Localizable.strings", + """ + |"key" = "Namespaced"; + | + | + """.trimMargin(), + ) + data.assertFile( + "cs.lproj/Localizable.stringsdict", + """ + | + | + | + | + | + | key + | + | NSStringLocalizedFormatKey + | %#${'$'}{#@format@} + | format + | + | one + | %lld den + | few + | dny + | other + | %lld dnů + | + | + | + | + | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.lproj/Localizable.strings", + """ + |"item" = "I will be first {icuParam}"; + | + | + """.trimMargin(), + ) + data.assertFile( + "cs.lproj/Localizable.stringsdict", + """ + | + | + | + | + | + | key3 + | + | NSStringLocalizedFormatKey + | %#${'$'}{#@format@} + | format + | + | one + | %lld den %@ + | few + | %lld dny + | other + | %lld dní + | + | + | + | + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): AppleStringsStringsdictExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}'", + ) + } + return getExporter(built.translations, true) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.lproj/Localizable.strings", + """ + |"item" = "I will be first {icuParam}"; + | + | + """.trimMargin(), + ) + data.assertFile( + "cs.lproj/Localizable.stringsdict", + """ + | + | + | + | + | + | key3 + | + | NSStringLocalizedFormatKey + | %#${'$'}{#@format@} + | format + | + | one + | # den {icuParam} + | few + | # dny + | other + | # dní + | + | + | + | + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): AppleStringsStringsdictExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam}", + ) + } + return getExporter(built.translations, false) + } + + private fun getExporter() = + getExporter( + listOf( + ExportTranslationView( + 1, + "Hello! I am great today! There you have some params " + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key"), + "en", + ), + ExportTranslationView( + 1, + "Namespaced", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", namespace = "homepage"), + "en", + ), + ExportTranslationView( + 1, + "{count, plural, one {# day} other {# days}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", namespace = "homepage", isPlural = true), + "en", + ), + ExportTranslationView( + 1, + "Namespaced", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", namespace = "homepage"), + "cs", + ), + ExportTranslationView( + 1, + "{count, plural, one {# day} other {# days}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", isPlural = true), + "en", + ), + ExportTranslationView( + 1, + "{count, plural, one {# den} few {dny} other {# dnů}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", isPlural = true), + "cs", + ), + ), + ) + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): AppleStringsStringsdictExporter { + return AppleStringsStringsdictExporter( + translations = translations, + exportParams = ExportParams(), + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) + } + + private fun getExporter(translations: List): AppleStringsStringsdictExporter { + return AppleStringsStringsdictExporter( + translations = translations, + exportParams = ExportParams(), + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/ForceIcuEscaperTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/ForceIcuEscaperTest.kt new file mode 100644 index 0000000000..a01b6851fe --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/ForceIcuEscaperTest.kt @@ -0,0 +1,58 @@ +package io.tolgee.unit.formats.escaping + +import io.tolgee.formats.escaping.ForceIcuEscaper +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ForceIcuEscaperTest { + @Test + fun testHandlesParameter() { + assertThat(ForceIcuEscaper("this {is} variant").escaped).isEqualTo("this '{'is'}' variant") + } + + @Test + fun testHandlesAlreadyEscapedParameter() { + assertThat(ForceIcuEscaper("this '{is}' variant").escaped).isEqualTo("this '''{'is'}''' variant") + } + + @Test + fun testHandlesTextWithApostrophe() { + assertThat(ForceIcuEscaper("apostrophe ' is here").escaped).isEqualTo("apostrophe ' is here") + } + + @Test + fun testEscapesHash() { + assertThat(ForceIcuEscaper("hash # is here", escapeHash = true).escaped).isEqualTo("hash '#' is here") + } + + @Test + fun testHandlesDoubleQuotes() { + assertThat( + ForceIcuEscaper("this is '' not {param} escaped").escaped, + ).isEqualTo("this is '''' not '{'param'}' escaped") + } + + @Test + fun testHandlesTripleQuotes() { + assertThat( + ForceIcuEscaper("this is ''' actually #' escaped", escapeHash = true).escaped, + ).isEqualTo("this is ''''' actually '#''' escaped") + } + + @Test + fun testTakesHashAsEscapeCharacter() { + assertThat( + ForceIcuEscaper("should be '# }' escaped", escapeHash = true).escaped, + ).isEqualTo("should be '''#' '}''' escaped") + } + + @Test + fun testEscapesDanglingEscapeAtTheEnd() { + assertThat(ForceIcuEscaper("test '").escaped).isEqualTo("test ''") + } + + @Test + fun testDoesntTakeTagsEscapesIntoConsideration() { + assertThat(ForceIcuEscaper("'<'").escaped).isEqualTo("'<''") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/IcuUnescaperTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/IcuUnescaperTest.kt new file mode 100644 index 0000000000..b11387e6aa --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/IcuUnescaperTest.kt @@ -0,0 +1,64 @@ +package io.tolgee.unit.formats.escaping + +import io.tolgee.formats.escaping.IcuUnescper +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class IcuUnescaperTest { + @Test + fun `it escapes`() { + IcuUnescper("'{hello}', my friend!").unescaped.assert.isEqualTo("{hello}, my friend!") + } + + @Test + fun `it escapes apostrophes`() { + IcuUnescper( + "we are not entering escaped section: '''' " + + "so it doesn't ' have to be doubled. " + + "This sequence: '{' should be immediately closed", + ).unescaped.assert.isEqualTo( + "we are not entering escaped section: '' " + + "so it doesn't ' have to be doubled. " + + "This sequence: { should be immediately closed", + ) + } + + @Test + fun `it works for weird case`() { + IcuUnescper( + "'What ' complex '''' '{' string # ", + false, + ).unescaped.assert.isEqualTo("'What ' complex '' { string # ") + } + + @Test + fun `removes the escape char on end of string`() { + val escaped = "Another ''''' more complex ' '''{ string }''' with many weird '} cases ''''}'" + IcuUnescper(escaped, false) + .unescaped.assert.isEqualTo("Another ''' more complex ' '{ string }' with many weird } cases ''}") + } + + @Test + fun `it it escapes escaped`() { + IcuUnescper( + "'''{'", + false, + ).unescaped.assert.isEqualTo("'{") + } + + @Test + fun `it escapes plurals`() { + IcuUnescper( + "What a '#' plural form", + isPlural = true, + ).unescaped.assert.isEqualTo("What a # plural form") + } + + @Test + fun `it unescapes inner sequence correctly`() { + IcuUnescper( + "'{ '' 'lakjsa'.", + isPlural = true, + ).unescaped.assert.isEqualTo("{ ' lakjsa'.") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/PluralFormIcuEscaperTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/PluralFormIcuEscaperTest.kt new file mode 100644 index 0000000000..3cf7df78b3 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/PluralFormIcuEscaperTest.kt @@ -0,0 +1,50 @@ +package io.tolgee.unit.formats.escaping + +import io.tolgee.formats.escaping.PluralFormIcuEscaper +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class PluralFormIcuEscaperTest { + @Test + fun `it escapes`() { + PluralFormIcuEscaper("{hello}, my friend!").escaped.assert.isEqualTo("'{hello}', my friend!") + } + + @Test + fun `it works for weird case`() { + PluralFormIcuEscaper( + "'What ' complex '' { string # ", + false, + ).escaped.assert.isEqualTo("'What ' complex '' '{' string # ") + } + + @Test + fun `it it escapes escaped`() { + PluralFormIcuEscaper( + "'{", + false, + ).escaped.assert.isEqualTo("'{'") + } + + @Test + fun `it escapes apostrophes`() { + PluralFormIcuEscaper( + "we are not entering escaped section: '' " + + "so it doesn't ' have to be doubled. " + + "This sequence: { should be immediately closed", + ) + .escaped.assert.isEqualTo( + "we are not entering escaped section: '' " + + "so it doesn't ' have to be doubled. " + + "This sequence: '{' should be immediately closed", + ) + } + + @Test + fun `it escapes plurals`() { + PluralFormIcuEscaper( + "What a # plural form", + escapeHash = true, + ).escaped.assert.isEqualTo("What a '#' plural form") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/TwoWayEscapingTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/TwoWayEscapingTest.kt new file mode 100644 index 0000000000..46e179391d --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/escaping/TwoWayEscapingTest.kt @@ -0,0 +1,44 @@ +package io.tolgee.unit.formats.escaping + +import io.tolgee.formats.escaping.ForceIcuEscaper +import io.tolgee.formats.escaping.IcuUnescper +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class TwoWayEscapingTest { + @Test + fun `it works`() { + testString("'What ' complex '' { string # ", false) + testString("''", false) + testString("'#'", false) + testString("{}", false) + testString("{aa}", false) + testString("'{", false) + testString("'{ }", false) + testString("'{ }}", false) + testString("{", false) + testString("''", false) + testString("Another ''' more complex ' '{ string }' with many weird } cases '", false) + testString("Another ''' more complex ' '{ string }' with many weird } cases '}", false) + testString("this {is} variant", false) + testString("this '{is}' variant", false) + testString("apostrophe ' is here", false) + testString("hash # is here", false) + testString("this is '' not {param} escaped", false) + testString("this is ''' actually #' escaped", false) + testString("should be '# }' escaped", false) + testString("test '", false) + testString("'<'", false) + } + + fun testString( + string: String, + plural: Boolean, + ) { + val escaped = ForceIcuEscaper(string, plural).escaped + val unescaped = IcuUnescper(escaped, plural).unescaped + unescaped.assert.describedAs( + "\n\nInput:\n$string \n\nEscaped:\n$escaped \n\nUnescpaed: \n$unescaped", + ).isEqualTo(string) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/in/FlutterArbFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/in/FlutterArbFormatProcessorTest.kt new file mode 100644 index 0000000000..8b42d81323 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/in/FlutterArbFormatProcessorTest.kt @@ -0,0 +1,168 @@ +package io.tolgee.unit.formats.fluttter.`in` + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.formats.flutter.`in`.FlutterArbFileProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.customEquals +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class FlutterArbFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("app_en.arb", "src/test/resources/import/flutter/app_en.arb") + } + + // This is how to generate the test: + // 1. run the test in debug mode + // 2. copy the result of calling: generateTestsForImportResult(mockUtil.fileProcessorContext) from the debug window + @Test + fun `returns correct parsed result`() { + FlutterArbFileProcessor(mockUtil.fileProcessorContext, jacksonObjectMapper()).process() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "helloWorld") + .assertSingle { + hasText("Hello World!") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "dogsCount") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {I have one dog.} + other {I have {count} dogs.} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "simpleDogCount") + .assertSingle { + hasText("Dogs count: {count}") + } + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertKey("helloWorld") { + custom.assert.isNull() + description.assert.isEqualTo("The conventional newborn programmer greeting") + } + mockUtil.fileProcessorContext.assertKey("dogsCount") { + customEquals( + """ + { + "_flutterArbPlaceholders" : { + "count" : { + "type" : "int", + "optionalParameters" : { + "decimalDigits" : 1 + } + } + } + } + """.trimIndent(), + ) + description.assert.isEqualTo("The conventional newborn programmer greeting") + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "helloWorld") + .assertSingle { + hasText("Hello World! {name}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "dogsCount") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {I have one dog.} + other {I have '{'count'}' dogs.} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "helloWorld") + .assertSingle { + hasText("Hello World! {name}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "dogsCount") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {I have one dog.} + other {I have {count} dogs.} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("dogsCount") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "helloWorld") + .assertSingle { + hasText("Hello World! {name}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "dogsCount") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {I have one dog.} + other {I have {count} dogs.} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "values-en/app_en.arb", + "src/test/resources/import/flutter/app_en_params.arb", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + FlutterArbFileProcessor(mockUtil.fileProcessorContext, jacksonObjectMapper()).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/out/FlutterArbFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/out/FlutterArbFileExporterTest.kt new file mode 100644 index 0000000000..d82462f5c8 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/fluttter/out/FlutterArbFileExporterTest.kt @@ -0,0 +1,210 @@ +package io.tolgee.unit.formats.fluttter.out + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.flutter.out.FlutterArbFileExporter +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.testing.assert +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class FlutterArbFileExporterTest { + @Test + fun exports() { + val exporter = getExporter() + + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + // generate this with: + // generateTestsForExportResult(data) + + data.assertFile( + "app_cs.arb", + """ + |{ + | "@@locale" : "cs", + | "key1" : "Ahoj! I{number}, {name}, {number}, {number}", + | "key3" : "{count, plural, one {{count} den} few {{count} dny} other {{count} dní}}" + |} + """.trimMargin(), + ) + data.assertFile( + "app_en.arb", + """ + |{ + | "@@locale" : "en", + | "key3" : "{count, plural, other {{count}}}", + | "@key3" : { + | "description" : "What a count", + | "placeholders" : { + | "count" : { + | "type" : "int" + | } + | } + | } + |} + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "app_cs.arb", + """ + |{ + | "@@locale" : "cs", + | "key3" : "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + | "item" : "I will be first {icuParam, number}" + |} + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): FlutterArbFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam, number}", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "app_cs.arb", + """ + |{ + | "@@locale" : "cs", + | "key3" : "{count, plural, one {{count} den {icuParam}} few {{count} dny} other {{count} dní}}", + | "item" : "I will be first {icuParam} {hello}" + |} + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): FlutterArbFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}' {hello, number}", + ) + } + return getExporter(built.translations, true) + } + + private fun Map.assertFile( + file: String, + content: String, + ) { + this[file]!!.assert.isEqualTo(content) + } + + private fun getExporter(): FlutterArbFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key1", + text = + "Ahoj! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + baseText = + "Hello! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + ) + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + ) { + key.isPlural = true + key.custom = + mapOf( + "_flutterArbPlaceholders" to + mapOf( + "count" to + mapOf( + "type" to "int", + ), + ), + ) + key.description = "What a count" + } + + add( + languageTag = "en", + keyName = "key3", + text = "{count}", + ) { + key.isPlural = true + key.custom = + mapOf( + "_flutterArbPlaceholders" to + mapOf( + "count" to + mapOf( + "type" to "int", + ), + ), + ) + key.description = "What a count" + } + } + return getExporter(built.translations) + } +} + +private fun getExporter(translations: List): FlutterArbFileExporter { + return FlutterArbFileExporter( + translations = translations, + exportParams = ExportParams(), + baseLanguageTag = "en", + objectMapper = jacksonObjectMapper(), + ) +} + +private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, +): FlutterArbFileExporter { + return FlutterArbFileExporter( + translations = translations, + exportParams = ExportParams(), + baseLanguageTag = "en", + objectMapper = jacksonObjectMapper(), + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) +} + +private fun getExported(exporter: FlutterArbFileExporter): Map { + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + return data +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonFormatProcessorTest.kt new file mode 100644 index 0000000000..e0dc9199f1 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonFormatProcessorTest.kt @@ -0,0 +1,170 @@ +package io.tolgee.unit.formats.json.`in` + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.formats.json.`in`.JsonFileProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class JsonFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + // This is how to generate the test: + // 1. run the test in debug mode + // 2. copy the result of calling: + // io.tolgee.unit.util.generateTestsForImportResult(mockUtil.fileProcessorContext) + // from the debug window + @Test + fun `returns correct parsed result`() { + mockUtil.mockIt("example.json", "src/test/resources/import/json/example.json") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("example", "common.save") + mockUtil.fileProcessorContext.assertTranslations("example", "array[0]") + mockUtil.fileProcessorContext.assertTranslations("example", "array[1]") + .assertSingle { + hasText("two") + } + mockUtil.fileProcessorContext.assertTranslations("example", "array[2]") + .assertSingle { + hasText("three") + } + mockUtil.fileProcessorContext.assertTranslations("example", "a.b.c") + .assertSingle { + hasText("This is nested hard.") + } + mockUtil.fileProcessorContext.assertTranslations("example", "a.b.d[0]") + .assertSingle { + hasText("one") + } + mockUtil.fileProcessorContext.assertTranslations("example", "a.b.d[1]") + .assertSingle { + hasText("two") + } + mockUtil.fileProcessorContext.assertTranslations("example", "a.b.d[2]") + .assertSingle { + hasText("three") + } + mockUtil.fileProcessorContext.assertTranslations("example", "boolean") + .assertSingle { + hasText("true") + } + mockUtil.fileProcessorContext.keys.assert.containsKeys("null") + } + + @Test + fun `returns correct parsed result (root array)`() { + mockUtil.mockIt("example.json", "src/test/resources/import/json/example_root_array.json") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("example", "[0]") + .assertSingle { + hasText("item 1") + } + mockUtil.fileProcessorContext.assertTranslations("example", "[1]") + .assertSingle { + hasText("item 2") + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one '#' '{'icuParam'}'} + other {Hello other '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "en.json", + "src/test/resources/import/json/example_params.json", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + JsonFileProcessor(mockUtil.fileProcessorContext, jacksonObjectMapper()).process() + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/export/exporters/JsonFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/out/JsonFileExporterTest.kt similarity index 58% rename from backend/app/src/test/kotlin/io/tolgee/service/export/exporters/JsonFileExporterTest.kt rename to backend/data/src/test/kotlin/io/tolgee/unit/formats/json/out/JsonFileExporterTest.kt index e65f615a48..7bd6d08967 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/export/exporters/JsonFileExporterTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/out/JsonFileExporterTest.kt @@ -1,12 +1,17 @@ -package io.tolgee.service.export.exporters +package io.tolgee.unit.formats.json.out import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.formats.json.out.JsonFileExporter import io.tolgee.model.enums.TranslationState import io.tolgee.service.export.dataProvider.ExportKeyView import io.tolgee.service.export.dataProvider.ExportTranslationView import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList import net.javacrumbs.jsonunit.assertj.assertThatJson import org.junit.jupiter.api.Test import java.io.InputStream @@ -98,6 +103,74 @@ class JsonFileExporterTest { assertThat(exported.getFileTextContent("en.json")).contains("\n").contains(" ") } + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.json", + """ + |{ + | "key3" : "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + | "item" : "I will be first {icuParam, number}" + |} + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): JsonFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam, number}", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.json", + """ + |{ + | "key3" : "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}", + | "item" : "I will be first '{'icuParam'}' {hello, number}" + |} + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): JsonFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}' {hello, number}", + ) + } + return getExporter(built.translations, true) + } + private fun Map.getFileTextContent(fileName: String): String { return this[fileName]!!.bufferedReader().readText() } @@ -111,10 +184,27 @@ class JsonFileExporterTest { val split = keyDef.split(":").toMutableList() val keyName = split.removeLast() val namespace = split.removeLastOrNull() - val key = ExportKeyView(1, keyName, namespace) + val key = ExportKeyView(1, keyName, namespace = namespace) val trans = ExportTranslationView(1, "text", TranslationState.TRANSLATED, key, "en") key.translations["en"] = trans trans } } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): JsonFileExporter { + return JsonFileExporter( + translations = translations, + exportParams = ExportParams(), + convertMessage = { message, isPlural -> + IcuToGenericFormatMessageConvertor( + message, + isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + }, + ) + } } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/FormatDetectorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/FormatDetectorTest.kt new file mode 100644 index 0000000000..f07a6b74a0 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/FormatDetectorTest.kt @@ -0,0 +1,30 @@ +package io.tolgee.unit.formats.po.`in` + +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.formats.po.`in`.FormatDetector +import io.tolgee.util.FileProcessorContextMockUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class FormatDetectorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.po", "src/test/resources/import/po/example.po") + } + + @Test + fun `returns C format`() { + val detector = FormatDetector(listOf("%jd %hhd", "%d %s", "d %s")) + assertThat(detector()).isEqualTo(PoSupportedMessageFormat.C) + } + + @Test + fun `returns PHP format`() { + val detector = FormatDetector(listOf("%b %d", "%d %s", "d %s")) + assertThat(detector()).isEqualTo(PoSupportedMessageFormat.PHP) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt new file mode 100644 index 0000000000..a88dea18a5 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt @@ -0,0 +1,163 @@ +package io.tolgee.unit.formats.po.`in` + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.formats.po.`in`.PoFileProcessor +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.description +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class PoFileProcessorTest { + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + @Test + fun `processes standard file correctly`() { + mockImportFile("example.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + assertThat(mockUtil.fileProcessorContext.languages).hasSize(1) + assertThat(mockUtil.fileProcessorContext.translations).hasSize(8) + val text = mockUtil.fileProcessorContext.translations["%d page read."]?.get(0)?.text + assertThat(text) + .isEqualTo( + "{0, plural,\n" + + "one {Eine Seite gelesen wurde.}\n" + + "other {{0, number} Seiten gelesen wurden.}\n" + + "}", + ) + assertThat(mockUtil.fileProcessorContext.translations.values.toList()[2][0].text) + .isEqualTo("Willkommen zurück, {0}! Dein letzter Besuch war am {1}") + } + + @Test + fun `adds metadata`() { + mockImportFile("example.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + val keyMeta = + mockUtil.fileProcessorContext.keys[ + "We connect developers and translators around the globe " + + "in Tolgee for a fantastic localization experience.", + ]!!.keyMeta!! + assertThat(keyMeta.description).isEqualTo( + "This is the text that should appear next to menu accelerators * " + + "that use the super key. If the text on this key isn't typically * " + + "translated on keyboards used for your language, don't translate * this.", + ) + assertThat(keyMeta.codeReferences).hasSize(6) + assertThat(keyMeta.codeReferences[0].path).isEqualTo("light_interface.c") + assertThat(keyMeta.codeReferences[0].line).isEqualTo(196) + } + + @Test + fun `processes windows newlines`() { + val string = jacksonObjectMapper().readValue(File("src/test/resources/import/po/windows-newlines.po.json")) + assertThat(string).contains("\r\n") + + mockImportFile("windows-newlines.po.json") + mockUtil.fileProcessorContext.file.data = string.encodeToByteArray() + PoFileProcessor(mockUtil.fileProcessorContext).process() + assertThat(mockUtil.fileProcessorContext.languages).hasSize(1) + assertThat(mockUtil.fileProcessorContext.translations).hasSize(1) + assertThat(mockUtil.fileProcessorContext.translations.values.toList()[0][0].text) + .isEqualTo("# Hex код (#fff)") + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("de", "hello") + .assertSingle { + hasText("Hi %d {icuParam}") + } + mockUtil.fileProcessorContext.assertTranslations("de", "%d page read.") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Hallo %d '{'icuParam'}'} + other {Hallo %d '{'icuParam'}'} + } + """.trimIndent(), + ) + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("de", "hello") + .assertSingle { + hasText("Hi %d '{'icuParam'}'") + } + mockUtil.fileProcessorContext.assertTranslations("de", "%d page read.") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Hallo %d '{'icuParam'}'} + other {Hallo %d '{'icuParam'}'} + } + """.trimIndent(), + ) + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("de", "hello") + .assertSingle { + hasText("Hi {0, number} '{'icuParam'}'") + } + mockUtil.fileProcessorContext.assertTranslations("de", "%d page read.") + .assertSinglePlural { + hasText( + """ + {0, plural, + one {Hallo {0, number} '{'icuParam'}'} + other {Hallo {0, number} '{'icuParam'}'} + } + """.trimIndent(), + ) + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "en.po", + "src/test/resources/import/po/example_params.po", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + PoFileProcessor(mockUtil.fileProcessorContext).process() + } + + private fun mockImportFile(fileName: String) { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.po", "src/test/resources/import/po/$fileName") + } + + lateinit var mockUtil: FileProcessorContextMockUtil +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt new file mode 100644 index 0000000000..247198c59b --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt @@ -0,0 +1,29 @@ +package io.tolgee.unit.formats.po.`in` + +import io.tolgee.formats.po.`in`.PoParser +import io.tolgee.util.FileProcessorContextMockUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PoParserTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.po", "src/test/resources/import/po/example.po") + } + + @Test + fun `returns correct parsed result`() { + val result = PoParser(mockUtil.fileProcessorContext)() + assertThat(result.translations).hasSizeGreaterThan(8) + assertThat(result.translations[5].msgstrPlurals).hasSize(2) + assertThat(result.translations[5].msgstrPlurals!![0].toString()).isEqualTo("Eine Seite gelesen wurde.") + assertThat(result.translations[5].msgstrPlurals!![1].toString()).isEqualTo("%d Seiten gelesen wurden.") + assertThat(result.translations[2].meta.translatorComments).hasSize(2) + assertThat(result.translations[2].meta.translatorComments[0]).isEqualTo("some other comment") + assertThat(result.translations[2].meta.extractedComments).hasSize(4) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoToICUConverterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoToICUConverterTest.kt new file mode 100644 index 0000000000..bac066f015 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoToICUConverterTest.kt @@ -0,0 +1,106 @@ +package io.tolgee.unit.formats.po.`in` + +import io.tolgee.formats.po.`in`.messageConvertors.PoCToIcuImportMessageConvertor +import io.tolgee.formats.po.`in`.messageConvertors.PoPhpToIcuImportMessageConvertor +import io.tolgee.formats.po.`in`.messageConvertors.PoPythonToIcuImportMessageConvertor +import io.tolgee.util.FileProcessorContextMockUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PoToICUConverterTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.po", "src/test/resources/import/po/example.po") + } + + @Test + fun testPhpPlurals() { + val result = + PoPhpToIcuImportMessageConvertor().convert( + rawData = + mapOf( + 0 to "Petr má jednoho psa.", + 1 to "Petr má %d psi.", + 2 to "Petr má %d psů.", + ), + languageTag = "cs", + convertPlaceholders = true, + ).message + assertThat(result).isEqualTo( + "{0, plural,\n" + + "one {Petr má jednoho psa.}\n" + + "few {Petr má {0, number} psi.}\n" + + "other {Petr má {0, number} psů.}\n" + + "}", + ) + } + + @Test + fun testPhpMessage() { + val result = + PoPhpToIcuImportMessageConvertor().convert("hello this is string %s, this is digit %d", "en").message + assertThat(result).isEqualTo("hello this is string {0}, this is digit {1, number}") + } + + @Test + fun testPhpMessageEscapes() { + val result = + PoPhpToIcuImportMessageConvertor().convert("%%s %%s %%%s %%%%s", "cs").message + assertThat(result).isEqualTo("%s %s %{0} %%s") + } + + @Test + fun testPhpMessageWithFlags() { + val result = + PoPhpToIcuImportMessageConvertor().convert("%+- 'as %+- 10s %1$'a +-010s", "cs").message + assertThat(result).isEqualTo("%+- 'as %+- 10s %1$'a +-010s") + } + + @Test + fun testPhpMessageMultiple() { + val result = + PoPhpToIcuImportMessageConvertor().convert("%s %d %d %s", "cs").message + assertThat(result).isEqualTo("{0} {1, number} {2, number} {3}") + } + + @Test + fun testCMessage() { + val result = + PoCToIcuImportMessageConvertor().convert("%s %d %c %+- #0f %+- #0llf %+-hhs %0hs {hey} %jd", "cs").message + assertThat( + result, + ).isEqualTo("{0} {1, number} %c %+- #0f %+- #0llf %+-hhs %0hs '{'hey'}' %jd") + } + + @Test + fun testPythonMessage() { + val result = + PoPythonToIcuImportMessageConvertor().convert( + "%(one)s %(two)d %(three)+- #0f %(four)+- #0lf %(five)+-hs %(six)0hs %(seven)ld {hey}", + "cs", + true, + ).message + assertThat( + result, + ).isEqualTo( + "{one} {two, number} %(three)+- #0f %(four)+- #0lf %(five)+-hs %(six)0hs %(seven)ld '{'hey'}'", + ) + } + + @Test + fun testPhpMessageKey() { + val result = + PoPhpToIcuImportMessageConvertor().convert( + "%3${'$'}d hello this is string %2${'$'}s, this is digit %1${'$'}d, and another digit %s", + "cs", + true, + ).message + + assertThat(result) + .isEqualTo("{2, number} hello this is string {1}, this is digit {0, number}, and another digit {3}") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/BaseIcuMessageToPoConvertorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/BaseIcuMessageToPoConvertorTest.kt new file mode 100644 index 0000000000..a46cee579e --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/BaseIcuMessageToPoConvertorTest.kt @@ -0,0 +1,76 @@ +package io.tolgee.unit.formats.po.out + +import io.tolgee.formats.po.out.BaseIcuMessageToPoConvertor +import io.tolgee.formats.po.out.php.PhpFromIcuParamConvertor +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class BaseIcuMessageToPoConvertorTest { + @Test + fun `converts simple message`() { + BaseIcuMessageToPoConvertor( + "Hello {hello} {hello, number} {hello, number, .00}", + PhpFromIcuParamConvertor(), + forceIsPlural = false, + ).convert().singleResult.assert.isEqualTo("Hello %s %d %.2f") + } + + @Test + fun `converts with plurals`() { + val forms = + BaseIcuMessageToPoConvertor( + "{0, plural, one {# dog} other {# dogs}}", + PhpFromIcuParamConvertor(), + forceIsPlural = true, + ) + .convert().formsResult + + forms!![0].assert.isEqualTo("%d dog") + forms[1].assert.isEqualTo("%d dogs") + } + + @Test + fun `converts czech with plurals`() { + val forms = + BaseIcuMessageToPoConvertor( + message = "{0, plural, one {# pes} few {# psi} other {# psů}}", + languageTag = "cs", + argumentConverter = PhpFromIcuParamConvertor(), + forceIsPlural = true, + ).convert().formsResult + + forms!![0].assert.isEqualTo("%d pes") + forms[1].assert.isEqualTo("%d psi") + forms[2].assert.isEqualTo("%d psů") + } + + @Test + fun `fallbacks to other`() { + val forms = + BaseIcuMessageToPoConvertor( + message = "{0, plural, one {# pes} other {# psů}}", + languageTag = "cs", + argumentConverter = PhpFromIcuParamConvertor(), + forceIsPlural = true, + ).convert().formsResult + + forms!![0].assert.isEqualTo("%d pes") + forms[1].assert.isEqualTo("%d psů") + forms[2].assert.isEqualTo("%d psů") + } + + @Test + fun `fallbacks works when unsupported form is present other`() { + val forms = + BaseIcuMessageToPoConvertor( + message = "{0, plural, one {# pes} many {# pesos} other {# psů}}", + languageTag = "cs", + argumentConverter = PhpFromIcuParamConvertor(), + forceIsPlural = true, + ).convert().formsResult + + forms!![0].assert.isEqualTo("%d pes") + forms[1].assert.isEqualTo("%d psů") + forms[2].assert.isEqualTo("%d psů") + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt new file mode 100644 index 0000000000..cacc90eb81 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt @@ -0,0 +1,352 @@ +package io.tolgee.unit.formats.po.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.formats.po.out.PoFileExporter +import io.tolgee.model.ILanguage +import io.tolgee.model.enums.TranslationState +import io.tolgee.service.export.dataProvider.ExportKeyView +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.testing.assert +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class PoFileExporterTest { + @Test + fun `exports plurals correctly`() { + val exporter = getPluralsExporter() + + val files = exporter.produceFiles().map { it.key to it.value.bufferedReader().readText() }.toMap() + files["cs.po"].assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: cs\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + "X-Generator: Tolgee\n" + + msgid "key" + msgstr[0] "%d den" + msgstr[1] "dny" + msgstr[2] "%d dnů"${"\n"} + """.trimIndent(), + ) + files["en.po"].assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: en\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 2; plural = (n !== 1)\n" + "X-Generator: Tolgee\n" + + msgid "key" + msgstr[0] "%d day" + msgstr[1] "%d days"${"\n"} + """.trimIndent(), + ) + } + + @Test + fun `exports simple`() { + val exporter = getSimpleExporter() + val files = exporter.produceFiles().map { it.key to it.value.bufferedReader().readText() }.toMap() + files["en.po"].assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: en\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 2; plural = (n !== 1)\n" + "X-Generator: Tolgee\n" + + msgid "key" + msgstr "Hello! %s, how are you?"${"\n"} + """.trimIndent(), + ) + + files["cs.po"].assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: cs\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + "X-Generator: Tolgee\n" + + msgid "key" + msgstr "Ahoj! %s, jak se máš?" + + msgid "key2" + msgstr "Ahoj! %3${"$"}s, jak se máš?"${"\n"} + """.trimIndent(), + ) + } + + @Test + fun `exports multilines correctly`() { + val exporter = getWithMultilinesExporter() + val files = exporter.produceFiles().map { it.key to it.value.bufferedReader().readText() }.toMap() + val cs = files["cs.po"] + cs.assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: cs\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + "X-Generator: Tolgee\n" + + msgid "" + "I am key\n" + "Look at me\n" + "Hello!" + msgstr[0] "" + "%d den\n" + "newline" + msgstr[1] "dny" + msgstr[2] "%d dnů" + + msgid "" + "I am key\n" + "Look at me\n" + "Hello!" + msgstr "" + "I am value\n" + "Look at me\n" + "Hello!" + + """.trimIndent(), + ) + } + + @Test + fun `escapes correctly`() { + val exporter = getEscapingTestExporter() + val files = exporter.produceFiles().map { it.key to it.value.bufferedReader().readText() }.toMap() + val cs = files["en.po"] + cs.assert.isEqualTo( + """ + msgid "" + msgstr "" + "Language: en\n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=UTF-8\n" + "Content-Transfer-Encoding: 8bit\n" + "Plural-Forms: nplurals = 2; plural = (n !== 1)\n" + "X-Generator: Tolgee\n" + + msgid "key" + msgstr "" + "\" \n" + " \\\" \\\\" + + """.trimIndent(), + ) + } + + private fun getSimpleExporter() = + getExporter( + listOf( + ExportTranslationView( + 1, + "Hello! {name}, how are you?", + TranslationState.TRANSLATED, + ExportKeyView(1, "key"), + "en", + ), + ExportTranslationView( + 1, + "Ahoj! {0}, jak se máš?", + TranslationState.TRANSLATED, + ExportKeyView(1, "key"), + "cs", + ), + ExportTranslationView( + 1, + "Ahoj! {2}, jak se máš?", + TranslationState.TRANSLATED, + ExportKeyView(1, "key2"), + "cs", + ), + ), + ) + + private fun getPluralsExporter() = + getExporter( + listOf( + ExportTranslationView( + 1, + "{count, plural, one {# day} other {# days}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", isPlural = true), + "en", + ), + ExportTranslationView( + 1, + "{count, plural, one {# den} few {dny} other {# dnů}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "key", isPlural = true), + "cs", + ), + ), + ) + + private fun getEscapingTestExporter() = + getExporter( + listOf( + ExportTranslationView( + 1, + "\" \n \\\" \\\\", + TranslationState.TRANSLATED, + ExportKeyView(1, "key"), + "en", + ), + ), + ) + + private fun getWithMultilinesExporter() = + getExporter( + listOf( + ExportTranslationView( + 1, + "{count, plural, one {# den\nnewline} few {dny} other {# dnů}}", + TranslationState.TRANSLATED, + ExportKeyView(1, "I am key\nLook at me\nHello!", isPlural = true), + "cs", + ), + ExportTranslationView( + 1, + "I am value\nLook at me\nHello!", + TranslationState.TRANSLATED, + ExportKeyView(1, "I am key\nLook at me\nHello!"), + "cs", + ), + ), + ) + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.po", + """ + |msgid "" + |msgstr "" + |"Language: cs\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + |"X-Generator: Tolgee\n" + | + |msgid "key3" + |msgstr[0] "%d den %s" + |msgstr[1] "%d dny" + |msgstr[2] "%d dní" + | + |msgid "item" + |msgstr "I will be first {icuParam}" + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): PoFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}'", + ) + } + return getExporter(built.translations, true) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.po", + """ + |msgid "" + |msgstr "" + |"Language: cs\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + |"X-Generator: Tolgee\n" + | + |msgid "key3" + |msgstr[0] "# den {icuParam}" + |msgstr[1] "# dny" + |msgstr[2] "# dní" + | + |msgid "item" + |msgstr "I will be first {icuParam}" + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): PoFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam}", + ) + } + return getExporter(built.translations, false) + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): PoFileExporter { + val baseLanguageMock = mock() + whenever(baseLanguageMock.tag).thenAnswer { "en" } + return PoFileExporter( + translations = translations, + exportParams = ExportParams(), + projectIcuPlaceholdersSupport = isProjectIcuPlaceholdersEnabled, + baseLanguage = baseLanguageMock, + baseTranslationsProvider = { listOf() }, + poSupportedMessageFormat = PoSupportedMessageFormat.PHP, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoMessageFormatsExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoMessageFormatsExporterTest.kt new file mode 100644 index 0000000000..04b8ad9172 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoMessageFormatsExporterTest.kt @@ -0,0 +1,104 @@ +package io.tolgee.unit.formats.po.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.po.PoSupportedMessageFormat +import io.tolgee.formats.po.out.PoFileExporter +import io.tolgee.model.ILanguage +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class PoMessageFormatsExporterTest { + @Test + fun php() { + val exporter = getExporter(PoSupportedMessageFormat.PHP) + val data = getExported(exporter) + data.assertFile( + "cs.po", + """ + |msgid "" + |msgstr "" + |"Language: cs\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + |"X-Generator: Tolgee\n" + | + |msgid "key3" + |msgstr "%3${'$'}d %2${'$'}s %1${'$'}s" + | + """.trimMargin(), + ) + } + +// @Test +// fun python() { +// val exporter = getExporter(PoSupportedMessageFormat.PYTHON) +// val data = getExported(exporter) +// data.assertFile( +// "cs.po", +// """ +// |msgid "" +// |msgstr "" +// |"Language: cs\n" +// |"MIME-Version: 1.0\n" +// |"Content-Type: text/plain; charset=UTF-8\n" +// |"Content-Transfer-Encoding: 8bit\n" +// |"Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" +// |"X-Generator: Tolgee\n" +// | +// |msgid "key3" +// |msgstr "%(2)d %(1)s %(0)s" +// | +// """.trimMargin(), +// ) +// } + + @Test + fun c() { + val exporter = getExporter(PoSupportedMessageFormat.C) + val data = getExported(exporter) + data.assertFile( + "cs.po", + """ + |msgid "" + |msgstr "" + |"Language: cs\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals = 3; plural = (n === 1 ? 0 : (n >= 2 && n <= 4) ? 1 : 2)\n" + |"X-Generator: Tolgee\n" + | + |msgid "key3" + |msgstr "%d %s %s" + | + """.trimMargin(), + ) + } + + private fun getExporter(poSupportedMessageFormat: PoSupportedMessageFormat): PoFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{2, number} {1} {0}", + ) + } + + val baseLanguageMock = mock() + whenever(baseLanguageMock.tag).thenAnswer { "en" } + return PoFileExporter( + translations = built.translations, + exportParams = ExportParams(), + baseLanguage = baseLanguageMock, + baseTranslationsProvider = { listOf() }, + poSupportedMessageFormat = poSupportedMessageFormat, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/in/PropertiesFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/in/PropertiesFileProcessorTest.kt new file mode 100644 index 0000000000..5ab417438a --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/in/PropertiesFileProcessorTest.kt @@ -0,0 +1,169 @@ +package io.tolgee.unit.formats.properties.`in` + +import io.tolgee.formats.properties.`in`.PropertiesFileProcessor +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PropertiesFileProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + @Test + fun `basic cases`() { + mockUtil.mockIt( + "messages_en.properties", + "src/test/resources/import/properties/example.properties", + ) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("messages_en", "key1") + .assertSingle { + hasText("Duplicated") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "escaping test") + .assertSingle { + hasText("Escaping = \\ = \n new line \n = = \"") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "array") + .assertSingle { + hasText("1, 2, 3") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "with.dots.s") + .assertSingle { + hasText("Hey") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "number") + .assertSingle { + hasText("1") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "boolean") + .assertSingle { + hasText("true") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "with_commnet") + .assertSingle { + hasText("with comment") + } + mockUtil.fileProcessorContext.assertTranslations("messages_en", "with_commnet_2") + .assertSingle { + hasText("with comment") + } + mockUtil.fileProcessorContext.assertKey("with_commnet") { + custom.assert.isNull() + description.assert.isEqualTo("A commnet") + } + mockUtil.fileProcessorContext.assertKey("with_commnet_2") { + custom.assert.isNull() + description.assert.isEqualTo("A commnet") + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara} '{escaped}',") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one '#' '{'icuParam'}'} + other {Hello other '{'icuParam'}' '''{'escaped'}'''} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara} '{escaped}',") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam} '{escaped}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara} '{escaped}',") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam} '{escaped}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "en.properties", + "src/test/resources/import/properties/example_params.properties", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + PropertiesFileProcessor(mockUtil.fileProcessorContext).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/out/JsonFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/out/JsonFileExporterTest.kt new file mode 100644 index 0000000000..c24876c61c --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/properties/out/JsonFileExporterTest.kt @@ -0,0 +1,162 @@ +package io.tolgee.unit.formats.properties.out + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.formats.properties.out.PropertiesFileExporter +import io.tolgee.model.enums.TranslationState +import io.tolgee.service.export.dataProvider.ExportKeyView +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test +import java.io.InputStream + +class PropertiesFileExporterTest { + @Suppress("UNCHECKED_CAST") + @Test + fun `it exports`() { + val exporter = getBasicExporter() + val data = getExported(exporter) + data.assertFile( + "cs.properties", + """ + |# I am a description + |key = {value, plural, other {I am basic text}} + |key.with.dots = I am key with dots + |escaping\ test = I am key with dots = a = \n # not a comment \n = = \\ yep +áěááššá + | + """.trimMargin(), + ) + } + + private fun getBasicExporter(): PropertiesFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key", + text = "I am basic text", + ) { + key.description = "I am a description" + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "key.with.dots", + text = "I am key with dots", + ) + add( + languageTag = "cs", + keyName = "escaping test", + text = "I am key with dots = a = \n # not a comment \n = = \\ yep +áěááššá", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.properties", + """ + |key3 = {count, plural, one {# den {icuParam}} few {# dny} other {# dní}} + |item = I will be first {icuParam, number} + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): PropertiesFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam, number}", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.properties", + """ + |key3 = {count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}} + |item = I will be first '{'icuParam'}' {hello, number} + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): PropertiesFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}' {hello, number}", + ) + } + return getExporter(built.translations, true) + } + + private fun Map.getFileTextContent(fileName: String): String { + return this[fileName]!!.bufferedReader().readText() + } + + private inline fun Map.parseFileContent(fileName: String): T { + return jacksonObjectMapper().readValue(this.getFileTextContent(fileName)) + } + + private fun generateTranslationsForKeys(keys: List): List { + return keys.sorted().map { keyDef -> + val split = keyDef.split(":").toMutableList() + val keyName = split.removeLast() + val namespace = split.removeLastOrNull() + val key = ExportKeyView(1, keyName, namespace = namespace) + val trans = ExportTranslationView(1, "text", TranslationState.TRANSLATED, key, "en") + key.translations["en"] = trans + trans + } + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): PropertiesFileExporter { + return PropertiesFileExporter( + translations = translations, + exportParams = ExportParams(), + convertMessage = { message, isPlural -> + IcuToGenericFormatMessageConvertor( + message, + isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + }, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/Xliff12FileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/Xliff12FileProcessorTest.kt new file mode 100644 index 0000000000..9a42e0e7d8 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/Xliff12FileProcessorTest.kt @@ -0,0 +1,206 @@ +package io.tolgee.unit.formats.xliff.`in` + +import io.tolgee.formats.xliff.`in`.Xliff12FileProcessor +import io.tolgee.formats.xliff.`in`.parser.XliffParser +import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType +import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLInputFactory + +class Xliff12FileProcessorTest { + private val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() + + private lateinit var xmlStreamReader: XMLEventReader + + private val xmlEventReader: XMLEventReader + get() { + val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() + return inputFactory.createXMLEventReader(mockUtil.importFileDto.data.inputStream()) + } + + private val parsed get() = XliffParser(xmlEventReader).parse() + + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.xliff", "src/test/resources/import/xliff/example.xliff") + } + + @Test + fun `processes xliff 12 file correctly`() { + Xliff12FileProcessor(mockUtil.fileProcessorContext, parsed).process() + assertThat(mockUtil.fileProcessorContext.languages).hasSize(2) + assertThat(mockUtil.fileProcessorContext.translations).hasSize(176) + assertThat(mockUtil.fileProcessorContext.translations["vpn.devices.removeA11Y"]!![0].text).isEqualTo("Remove %1") + assertThat(mockUtil.fileProcessorContext.translations["vpn.devices.removeA11Y"]!![0].language.name).isEqualTo("en") + assertThat(mockUtil.fileProcessorContext.translations["vpn.devices.removeA11Y"]!![1].text).isEqualTo("Eliminar %1") + assertThat( + mockUtil.fileProcessorContext.translations["vpn.devices.removeA11Y"]!![1].language.name, + ).isEqualTo("es-MX") + + val keyMeta = mockUtil.fileProcessorContext.keys["vpn.aboutUs.releaseVersion"]!!.keyMeta!! + assertThat(keyMeta.description).isEqualTo( + "Refers to the installed version." + + " For example: \"Release Version: 1.23\"", + ) + assertThat(keyMeta.codeReferences).hasSize(1) + assertThat(keyMeta.codeReferences[0].path).isEqualTo("../src/ui/components/VPNAboutUs.qml") + assertThat(mockUtil.fileProcessorContext.translations["systray.quit"]!![0].text).isEqualTo( + "", + ) + assertThat(mockUtil.fileProcessorContext.translations["systray.quit"]!![1].text) + .isEqualTo( + "", + ) + } + + @Test + fun `processes xliff 12 fast enough`() { + mockUtil.mockIt("example.xliff", "src/test/resources/import/xliff/larger.xlf") + xmlStreamReader = inputFactory.createXMLEventReader(mockUtil.importFileDto.data.inputStream()) + val start = System.currentTimeMillis() + Xliff12FileProcessor(mockUtil.fileProcessorContext, parsed).process() + assertThat(System.currentTimeMillis() - start).isLessThan(4000) + } + + @Test + fun `preserving spaces works correctly`() { + mockUtil.mockIt("example.xliff", "src/test/resources/import/xliff/preserving-spaces.xliff") + xmlStreamReader = inputFactory.createXMLEventReader(mockUtil.importFileDto.data.inputStream()) + Xliff12FileProcessor(mockUtil.fileProcessorContext, parsed).process() + mockUtil + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "1") + .assertSingle { + hasText(" Back") + } + mockUtil.fileProcessorContext.assertTranslations("en", "2") + .assertSingle { + hasText("Back") + } + mockUtil.fileProcessorContext.assertTranslations("en", "3") + .assertSingle { + hasText(" Back") + } + } + + @Test + fun `handles errors correctly`() { + mockUtil.mockIt("error_example.xliff", "src/test/resources/import/xliff/error_example.xliff") + Xliff12FileProcessor(mockUtil.fileProcessorContext, parsed).process() + assertThat(mockUtil.fileProcessorContext.translations).hasSize(2) + mockUtil.fileProcessorContext.fileEntity.issues.let { issues -> + assertThat(issues).hasSize(4) + assertThat(issues[0].type).isEqualTo(FileIssueType.TARGET_NOT_PROVIDED) + assertThat(issues[0].params[0].type).isEqualTo(FileIssueParamType.KEY_NAME) + assertThat(issues[0].params[0].value).isEqualTo("vpn.main.back") + assertThat(issues[1].type).isEqualTo(FileIssueType.ID_ATTRIBUTE_NOT_PROVIDED) + assertThat(issues[1].params[0].type).isEqualTo(FileIssueParamType.FILE_NODE_ORIGINAL) + assertThat(issues[1].params[0].value).isEqualTo("../src/platforms/android/androidauthenticationview.qml") + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one '#' '{'icuParam'}'} + other {Hello other '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {count, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "en.xliff", + "src/test/resources/import/xliff/example_params.xliff", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + Xliff12FileProcessor(mockUtil.fileProcessorContext, parsed).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/XliffFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/XliffFileProcessorTest.kt new file mode 100644 index 0000000000..447237fc86 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/in/XliffFileProcessorTest.kt @@ -0,0 +1,24 @@ +package io.tolgee.unit.formats.xliff.`in` + +import io.tolgee.formats.xliff.`in`.XliffFileProcessor +import io.tolgee.util.FileProcessorContextMockUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class XliffFileProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("example.xliff", "src/test/resources/import/xliff/example.xliff") + } + + @Test + fun `processes xliff 12 file`() { + XliffFileProcessor(mockUtil.fileProcessorContext).process() + assertThat(mockUtil.fileProcessorContext.languages).hasSize(2) + assertThat(mockUtil.fileProcessorContext.translations).hasSize(176) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/export/exporters/XliffFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/out/XliffFileExporterTest.kt similarity index 57% rename from backend/app/src/test/kotlin/io/tolgee/service/export/exporters/XliffFileExporterTest.kt rename to backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/out/XliffFileExporterTest.kt index 2942f4061a..7782d21fa4 100644 --- a/backend/app/src/test/kotlin/io/tolgee/service/export/exporters/XliffFileExporterTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/xliff/out/XliffFileExporterTest.kt @@ -1,20 +1,23 @@ -package io.tolgee.service.export.exporters +package io.tolgee.unit.formats.xliff.out import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.formats.xliff.out.XliffFileExporter import io.tolgee.model.Language import io.tolgee.model.enums.TranslationState import io.tolgee.service.export.dataProvider.ExportKeyView import io.tolgee.service.export.dataProvider.ExportTranslationView import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import org.w3c.dom.Attr import org.w3c.dom.Document import org.w3c.dom.Element import org.w3c.dom.Node import org.w3c.dom.NodeList import org.xml.sax.InputSource -import org.xml.sax.SAXParseException import java.io.StringReader import javax.xml.XMLConstants import javax.xml.parsers.DocumentBuilderFactory @@ -46,7 +49,7 @@ class XliffFileExporterTest { var fileContent = files["de.xlf"]!!.bufferedReader().readText() var transUnit = assertHasTransUnitAndReturn(fileContent, "en", "de") assertThat(transUnit.attribute("id").value).isEqualTo("A key") - assertThat(transUnit.selectNodes("./source")).isEmpty() + assertThat(transUnit.selectNodes("./source")[0].text).isEqualTo("") assertThat(transUnit.selectNodes("./target")[0].text).isEqualTo("Z translation") fileContent = files["en.xlf"]!!.bufferedReader().readText() @@ -92,6 +95,34 @@ class XliffFileExporterTest { assertThat(invalid.text).isEqualTo("Sweat jesus, this is invalid < HTML!") } + @Test + fun `respects xml space preserve`() { + val params = ExportParams() + + val files = + XliffFileExporter( + listOf( + ExportTranslationView( + 1, + "

Sweat jesus, this is HTML!

", + TranslationState.TRANSLATED, + ExportKeyView(1, "html_key", description = "Omg!\n This is really. \n preserved"), + "en", + ), + ), + exportParams = params, + baseTranslationsProvider = { listOf() }, + baseLanguage = Language().apply { tag = "en" }, + ).produceFiles() + + val fileContent = files["en.xlf"]!!.bufferedReader().readText() + fileContent.contains( + "Omg!\n" + + " This is really. \n" + + " preserved", + ) + } + private fun getHtmlTranslations(): List { val key = ExportKeyView(1, "html_key") val validHtmlTranslation = @@ -173,7 +204,7 @@ class XliffFileExporterTest { ).produceFiles() val validator: Validator - javaClass.classLoader.getResourceAsStream("xliff/xliff-core-1.2-transitional.xsd") + javaClass.classLoader.getResourceAsStream("import/xliff/xliff-core-1.2-transitional.xsd") .use { xsdInputStream -> validator = try { @@ -191,9 +222,7 @@ class XliffFileExporterTest { // de.xlf is invalid because of a missing a "source" element inside the "trans-unit". Should throw a SAXParseException. files["de.xlf"].use { invalidFileContent -> - assertThrows { - validator.validate(StreamSource(invalidFileContent)) - } + validator.validate(StreamSource(invalidFileContent)) } // en.xlf is valid @@ -201,4 +230,121 @@ class XliffFileExporterTest { validator.validate(StreamSource(validFileContent)) } } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.xlf", + """ + | + | + | + |
+ | + |
+ | + | + | + | {count, plural, one {# den {icuParam}} few {# dny} other {# dní}} + | + | + | + | I will be first {icuParam, number} + | + | + |
+ |
+ | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): XliffFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam, number}", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.xlf", + """ + | + | + | + |
+ | + |
+ | + | + | + | {count, plural, one {# den {icuParam, number} '{hey}'} few {# dny} other {# dní}} + | + | + | + | I will be first '{'icuParam'}' {hello, number} + | + | + |
+ |
+ | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersEnabledExporter(): XliffFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam, number} '{hey}'} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}' {hello, number}", + ) + } + return getExporter(built.translations, true) + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + ): XliffFileExporter { + return XliffFileExporter( + translations = translations, + exportParams = ExportParams(), + baseLanguage = Language().apply { tag = "en" }, + baseTranslationsProvider = { listOf() }, + convertMessage = { message, isPlural -> + IcuToGenericFormatMessageConvertor( + message, + isPlural, + isProjectIcuPlaceholdersEnabled, + ).convert() + }, + ) + } } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/PropertiesParserTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/PropertiesParserTest.kt deleted file mode 100644 index bb8a28738c..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/PropertiesParserTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.PropertyFileProcessor -import org.assertj.core.api.Assertions -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File - -class PropertiesParserTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("messages_en.properties", importMock) - importFileDto = - ImportFileDto( - "messages_en.properties", - File("src/test/resources/import/example.properties") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun `returns correct parsed result`() { - PropertyFileProcessor(fileProcessorContext).process() - Assertions.assertThat(fileProcessorContext.languages).hasSize(1) - Assertions.assertThat(fileProcessorContext.translations).hasSize(4) - val text = fileProcessorContext.translations["Register"]?.get(0)?.text - Assertions.assertThat(text).isEqualTo("Veuillez vous enregistrer sur la page suivante.") - val multiLineText = fileProcessorContext.translations["Cleanup"]?.get(0)?.text - Assertions.assertThat(multiLineText).hasLineCount(3) - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/FormatDetectorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/FormatDetectorTest.kt deleted file mode 100644 index bc72c08ae6..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/FormatDetectorTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.messageFormat - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.messageFormat.FormatDetector -import io.tolgee.service.dataImport.processors.messageFormat.SupportedFormat -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File - -class FormatDetectorTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("exmample.po", importMock) - importFileDto = - ImportFileDto( - "exmample.po", - File("src/test/resources/import/po/example.po") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun `returns C format`() { - val detector = FormatDetector(listOf("%jd %hhd", "%d %s", "d %s")) - assertThat(detector()).isEqualTo(SupportedFormat.C) - } - - @Test - fun `returns PHP format`() { - val detector = FormatDetector(listOf("%b %d", "%d %s", "d %s")) - assertThat(detector()).isEqualTo(SupportedFormat.PHP) - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/ToICUConverterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/ToICUConverterTest.kt deleted file mode 100644 index 67ab4f0d8e..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/messageFormat/ToICUConverterTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.messageFormat - -import com.ibm.icu.util.ULocale -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.messageFormat.SupportedFormat -import io.tolgee.service.dataImport.processors.messageFormat.ToICUConverter -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File - -class ToICUConverterTest { - private lateinit var context: FileProcessorContext - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("exmample.po", importMock) - importFileDto = - ImportFileDto( - "exmample.po", - File("src/test/resources/import/po/example.po") - .readBytes(), - ) - context = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun testPhpPlurals() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context).convertPoPlural( - mapOf( - 0 to "Petr má jednoho psa.", - 1 to "Petr má %d psi.", - 2 to "Petr má %d psů.", - ), - ) - assertThat(result).isEqualTo( - "{0, plural,\n" + - "one {Petr má jednoho psa.}\n" + - "few {Petr má {0, number} psi.}\n" + - "other {Petr má {0, number} psů.}\n" + - "}", - ) - } - - @Test - fun testPhpMessage() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context) - .convert("hello this is string %s, this is digit %d") - assertThat(result).isEqualTo("hello this is string {0}, this is digit {1, number}") - } - - @Test - fun testPhpMessageEscapes() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context) - .convert("%%s %%s %%%s %%%%s") - assertThat(result).isEqualTo("%s %s %{0} %%s") - } - - @Test - fun testPhpMessageWithFlags() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context) - .convert("%+- 'as %+- 10s %1$'a +-010s") - assertThat(result).isEqualTo("{0} {1} {0}") - } - - @Test - fun testPhpMessageMultiple() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context) - .convert("%s %d %d %s") - assertThat(result).isEqualTo("{0} {1, number} {2, number} {3}") - } - - @Test - fun testCMessage() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.C, context) - .convert("%s %d %c %+- #0f %+- #0llf %+-hhs %0hs %jd") - assertThat(result).isEqualTo("{0} {1, number} {2} {3, number} {4, number} {5} {6} {7, number}") - } - - @Test - fun testPythonMessage() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PYTHON, context) - .convert("%(one)s %(two)d %c %(three)+- #0f %(four)+- #0lf %(five)+-hs %(six)0hs %(seven)ld") - assertThat(result).isEqualTo("{one} {two, number} {2} {three, number} {four, number} {five} {six} {seven, number}") - } - - @Test - fun testPhpMessageKey() { - val result = - ToICUConverter(ULocale("cs"), SupportedFormat.PHP, context) - .convert("%3${'$'}d hello this is string %2${'$'}s, this is digit %1${'$'}d, and another digit %s") - - assertThat(result) - .isEqualTo("{2, number} hello this is string {1}, this is digit {0, number}, and another digit {3}") - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoFileProcessorTest.kt deleted file mode 100644 index 613add3ca5..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoFileProcessorTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.po - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.po.PoFileProcessor -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File -import java.io.InputStream - -class PoFileProcessorTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - private lateinit var file: File - - @Test - fun `processes standard file correctly`() { - mockImportFile("example.po") - PoFileProcessor(fileProcessorContext).process() - assertThat(fileProcessorContext.languages).hasSize(1) - assertThat(fileProcessorContext.translations).hasSize(8) - val text = fileProcessorContext.translations["%d pages read."]?.get(0)?.text - assertThat(text) - .isEqualTo( - "{0, plural,\n" + - "one {Eine Seite gelesen wurde.}\n" + - "other {{0, number} Seiten gelesen wurden.}\n" + - "}", - ) - assertThat(fileProcessorContext.translations.values.toList()[2][0].text) - .isEqualTo("Willkommen zurück, {0}! Dein letzter Besuch war am {1}") - } - - @Test - fun `adds metadata`() { - mockImportFile("example.po") - PoFileProcessor(fileProcessorContext).process() - val keyMeta = - fileProcessorContext.keys[ - "We connect developers and translators around the globe " + - "in Tolgee for a fantastic localization experience.", - ]!!.keyMeta!! - assertThat(keyMeta.comments).hasSize(2) - assertThat(keyMeta.comments[0].text).isEqualTo( - "This is the text that should appear next to menu accelerators" + - " * that use the super key. If the text on this key isn't typically" + - " * translated on keyboards used for your language, don't translate * this.", - ) - assertThat(keyMeta.comments[1].text).isEqualTo("some other comment and other") - assertThat(keyMeta.codeReferences).hasSize(6) - assertThat(keyMeta.codeReferences[0].path).isEqualTo("light_interface.c") - assertThat(keyMeta.codeReferences[0].line).isEqualTo(196) - } - - @Test - fun `processes windows newlines`() { - val string = jacksonObjectMapper().readValue(File("src/test/resources/import/po/windows-newlines.po.json")) - assertThat(string).contains("\r\n") - - mockImportFile(string.byteInputStream()) - PoFileProcessor(fileProcessorContext).process() - assertThat(fileProcessorContext.languages).hasSize(1) - assertThat(fileProcessorContext.translations).hasSize(1) - assertThat(fileProcessorContext.translations.values.toList()[0][0].text) - .isEqualTo("# Hex код (#fff)") - } - - private fun mockImportFile(fileName: String) { - file = File("src/test/resources/import/po/$fileName") - mockImportFile(file.inputStream()) - } - - private fun mockImportFile(inputStream: InputStream) { - importMock = mock() - importFile = ImportFile("exmample.po", importMock) - importFileDto = - ImportFileDto( - "exmample.po", - inputStream.readAllBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoParserTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoParserTest.kt deleted file mode 100644 index 298bc21ae5..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/po/PoParserTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.po - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.po.PoParser -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File - -class PoParserTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("exmample.po", importMock) - importFileDto = - ImportFileDto( - "exmample.po", - File("src/test/resources/import/po/example.po") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun `returns correct parsed result`() { - val result = PoParser(fileProcessorContext)() - assertThat(result.translations).hasSizeGreaterThan(8) - assertThat(result.translations[5].msgstrPlurals).hasSize(2) - assertThat(result.translations[5].msgstrPlurals!![0].toString()).isEqualTo("Eine Seite gelesen wurde.") - assertThat(result.translations[5].msgstrPlurals!![1].toString()).isEqualTo("%d Seiten gelesen wurden.") - assertThat(result.translations[2].meta.translatorComments).hasSize(2) - assertThat(result.translations[2].meta.translatorComments[0]).isEqualTo("some other comment") - assertThat(result.translations[2].meta.extractedComments).hasSize(4) - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/Xliff12FileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/Xliff12FileProcessorTest.kt deleted file mode 100644 index a88cf69572..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/Xliff12FileProcessorTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.xliff - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType -import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.xliff.Xliff12FileProcessor -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File -import javax.xml.stream.XMLEventReader -import javax.xml.stream.XMLInputFactory - -class Xliff12FileProcessorTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - private val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() - private lateinit var xmlStreamReader: XMLEventReader - - private val xmlEventReader: XMLEventReader - get() { - val inputFactory: XMLInputFactory = XMLInputFactory.newDefaultFactory() - return inputFactory.createXMLEventReader(importFileDto.data.inputStream()) - } - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("exmample.xliff", importMock) - importFileDto = - ImportFileDto( - "exmample.xliff", - File("src/test/resources/import/xliff/example.xliff") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun `processes xliff 12 file correctly`() { - Xliff12FileProcessor(fileProcessorContext, xmlEventReader).process() - assertThat(fileProcessorContext.languages).hasSize(2) - assertThat(fileProcessorContext.translations).hasSize(176) - assertThat(fileProcessorContext.translations["vpn.devices.removeA11Y"]!![0].text).isEqualTo("Remove %1") - assertThat(fileProcessorContext.translations["vpn.devices.removeA11Y"]!![0].language.name).isEqualTo("en") - assertThat(fileProcessorContext.translations["vpn.devices.removeA11Y"]!![1].text).isEqualTo("Eliminar %1") - assertThat(fileProcessorContext.translations["vpn.devices.removeA11Y"]!![1].language.name).isEqualTo("es-MX") - - val keyMeta = fileProcessorContext.keys["vpn.aboutUs.releaseVersion"]!!.keyMeta!! - assertThat(keyMeta.comments).hasSize(1) - assertThat(keyMeta.comments[0].text).isEqualTo( - "Refers to the installed version." + - " For example: \"Release Version: 1.23\"", - ) - assertThat(keyMeta.codeReferences).hasSize(1) - assertThat(keyMeta.codeReferences[0].path).isEqualTo("../src/ui/components/VPNAboutUs.qml") - assertThat(fileProcessorContext.translations["systray.quit"]!![0].text).isEqualTo( - "", - ) - assertThat(fileProcessorContext.translations["systray.quit"]!![1].text) - .isEqualTo( - "", - ) - } - - @Test - fun `processes xliff 12 fast enough`() { - importFileDto = - ImportFileDto( - "exmample.xliff", - File("src/test/resources/import/xliff/larger.xlf") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - xmlStreamReader = inputFactory.createXMLEventReader(importFileDto.data.inputStream()) - val start = System.currentTimeMillis() - Xliff12FileProcessor(fileProcessorContext, xmlEventReader).process() - assertThat(System.currentTimeMillis() - start).isLessThan(4000) - } - - @Test - fun `handles errors correctly`() { - importFileDto = - ImportFileDto( - "exmample.xliff", - File("src/test/resources/import/xliff/error_example.xliff").readBytes(), - ) - xmlStreamReader = inputFactory.createXMLEventReader(importFileDto.data.inputStream()) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - Xliff12FileProcessor(fileProcessorContext, xmlEventReader).process() - assertThat(fileProcessorContext.translations).hasSize(2) - fileProcessorContext.fileEntity.issues.let { issues -> - assertThat(issues).hasSize(4) - assertThat(issues[0].type).isEqualTo(FileIssueType.TARGET_NOT_PROVIDED) - assertThat(issues[0].params[0].type).isEqualTo(FileIssueParamType.KEY_NAME) - assertThat(issues[0].params[0].value).isEqualTo("vpn.main.back") - assertThat(issues[1].type).isEqualTo(FileIssueType.ID_ATTRIBUTE_NOT_PROVIDED) - assertThat(issues[1].params[0].type).isEqualTo(FileIssueParamType.FILE_NODE_ORIGINAL) - assertThat(issues[1].params[0].value).isEqualTo("../src/platforms/android/androidauthenticationview.qml") - } - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/XliffFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/XliffFileProcessorTest.kt deleted file mode 100644 index e45fdd52b5..0000000000 --- a/backend/data/src/test/kotlin/io/tolgee/unit/service/dataImport/processors/processors/xliff/XliffFileProcessorTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.tolgee.unit.service.dataImport.processors.processors.xliff - -import io.tolgee.dtos.dataImport.ImportFileDto -import io.tolgee.model.dataImport.Import -import io.tolgee.model.dataImport.ImportFile -import io.tolgee.service.dataImport.processors.FileProcessorContext -import io.tolgee.service.dataImport.processors.xliff.XliffFileProcessor -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import java.io.File - -class XliffFileProcessorTest { - private lateinit var importMock: Import - private lateinit var importFile: ImportFile - private lateinit var importFileDto: ImportFileDto - private lateinit var fileProcessorContext: FileProcessorContext - - @BeforeEach - fun setup() { - importMock = mock() - importFile = ImportFile("exmample.xliff", importMock) - importFileDto = - ImportFileDto( - "exmample.xliff", - File("src/test/resources/import/xliff/example.xliff") - .readBytes(), - ) - fileProcessorContext = FileProcessorContext(importFileDto, importFile) - } - - @Test - fun `processes xliff 12 file`() { - XliffFileProcessor(fileProcessorContext).process() - assertThat(fileProcessorContext.languages).hasSize(2) - assertThat(fileProcessorContext.translations).hasSize(176) - } -} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/util/exportAssertUtil.kt b/backend/data/src/test/kotlin/io/tolgee/unit/util/exportAssertUtil.kt new file mode 100644 index 0000000000..136bcf8984 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/util/exportAssertUtil.kt @@ -0,0 +1,17 @@ +package io.tolgee.unit.util + +import io.tolgee.service.export.exporters.FileExporter +import io.tolgee.testing.assert + +fun Map.assertFile( + file: String, + content: String, +) { + this[file]!!.assert.isEqualTo(content) +} + +fun getExported(exporter: FileExporter): Map { + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + return data +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt b/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt new file mode 100644 index 0000000000..d8de1c9f81 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt @@ -0,0 +1,106 @@ +package io.tolgee.unit.util + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.model.dataImport.ImportTranslation +import io.tolgee.service.dataImport.processors.FileProcessorContext + +/** + * Run this function in debug window to generate test result of each file processor + * When editing, do it in the debug window and copy the result to the test file + */ +@Suppress("unused") +fun generateTestsForImportResult(fileProcessorContext: FileProcessorContext): String { + val translations = fileProcessorContext.translations + val languageCount = fileProcessorContext.languages.size + val code = StringBuilder() + val i = { i: Int -> (1..i).joinToString("") { " " } } + val writeMutlilineString = ms@{ string: String?, indent: Int -> + string ?: return@ms + code.appendLine("""${i(indent)}${"\"\"\""}""") + code.appendLine(i(indent) + string.replace("\n", "\n${i(5)}")) + code.appendLine("""${i(indent)}${"\"\"\""}.trimIndent()""") + } + val escape = { str: String?, newLines: Boolean -> + str?.replace("\"", "\\\"").let { + if (newLines) { + return@let it?.replace("\n", "\\n") + } + it + }?.replace("\$", "\${'$'}") + } + code.appendLine("${i(2)}mockUtil.fileProcessorContext.assertLanguagesCount($languageCount)") + fileProcessorContext.translations.forEach { (keyName, translations) -> + val byLanguage = translations.groupBy { it.language.name } + byLanguage.forEach byLang@{ (language, translations) -> + code.appendLine("""${i(2)}mockUtil.fileProcessorContext.assertTranslations("$language", "$keyName")""") + translations.singleOrNull()?.let { translation -> + if (translation.isPlural) { + code.appendLine("""${i(3)}.assertSinglePlural {""") + code.appendLine("""${i(4)}hasText(""") + writeMutlilineString(escape(translation.text, false), 5) + code.appendLine("""${i(4)})""") + code.appendLine("""${i(4)}isPluralOptimized()""") + code.appendLine("""${i(3)}}""") + return@byLang + } + code.appendLine("""${i(3)}.assertSingle {""") + code.appendLine("""${i(4)}hasText("${escape(translation.text, true)}")""") + code.appendLine("""${i(3)}}""") + return@byLang + } + translations.firstIfAllSameOrNull()?.let { translation -> + code.appendLine("""${i(3)}.assertAllSame {""") + code.appendLine("""${i(4)}hasText("${escape(translation.text, true)}")""") + code.appendLine("""${i(3)}}""") + return@byLang + } + } + } + fileProcessorContext.keys.forEach { keyName, importKey -> + code.appendLine("""${i(2)}mockUtil.fileProcessorContext.assertKey("$keyName"){""") + val keyMeta = fileProcessorContext.keys[keyName]?.keyMeta + val custom = keyMeta?.custom + if (custom == null) { + code.appendLine("""${i(3)}custom.assert.isNull()""") + } else { + code.appendLine("""${i(3)}customEquals(""") + val customString = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(custom) + writeMutlilineString(customString, 4) + code.appendLine("""${i(3)})""") + } + val description = keyMeta?.description + if (description == null) { + code.appendLine("""${i(3)}description.assert.isNull()""") + } else { + code.appendLine("""${i(3)}description.assert.isEqualTo("${escape(keyMeta.description, true)}")""") + } + code.appendLine("""${i(2)}}""") + } + + return code.toString() +} + +private fun List.firstIfAllSameOrNull(): ImportTranslation? { + if (this.map { it.text }.toSet().size == 1) { + return this.first() + } + return null +} + +/** + * Run this function in debug window to generate test code for each export result + * When editing, do it in the debug window and copy the result to the test file + */ +@Suppress("unused") +fun generateTestsForExportResult(data: Map): String { + return data.map { + "data.assertFile(\"${it.key}\", \"\"\"\n" + + " |${ + it.value + .replace("\$", "\${'$'}") + .replace("\n", "\n |") + }\n" + + " \"\"\".trimMargin())" + } + .joinToString("\n") +} diff --git a/backend/data/src/test/kotlin/io/tolgee/util/FileProcessorContextMockUtil.kt b/backend/data/src/test/kotlin/io/tolgee/util/FileProcessorContextMockUtil.kt new file mode 100644 index 0000000000..c0bdd684cd --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/util/FileProcessorContextMockUtil.kt @@ -0,0 +1,54 @@ +package io.tolgee.util + +import io.tolgee.api.IImportSettings +import io.tolgee.component.KeyCustomValuesValidator +import io.tolgee.dtos.dataImport.ImportFileDto +import io.tolgee.model.dataImport.Import +import io.tolgee.model.dataImport.ImportFile +import io.tolgee.service.dataImport.processors.FileProcessorContext +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.springframework.context.ApplicationContext +import java.io.File + +class FileProcessorContextMockUtil { + private lateinit var importMock: Import + lateinit var importFile: ImportFile + lateinit var importFileDto: ImportFileDto + lateinit var fileProcessorContext: FileProcessorContext + + fun mockIt( + fileName: String, + resourcesFilePath: String, + convertPlaceholders: Boolean = true, + projectIcuPlaceholdersEnabled: Boolean = true, + ) { + importMock = mock() + importFile = ImportFile(fileName, importMock) + importFileDto = + ImportFileDto( + fileName, + File(resourcesFilePath) + .readBytes(), + ) + + val applicationContextMock: ApplicationContext = + Mockito.mock(ApplicationContext::class.java, Mockito.RETURNS_DEEP_STUBS) + val validator = Mockito.mock(KeyCustomValuesValidator::class.java) + Mockito.`when`(applicationContextMock.getBean(KeyCustomValuesValidator::class.java)).thenReturn(validator) + Mockito.`when`(validator.isValid(any())).thenReturn(true) + fileProcessorContext = + FileProcessorContext( + importFileDto, + importFile, + applicationContext = applicationContextMock, + importSettings = + object : IImportSettings { + override var overrideKeyDescriptions: Boolean = false + override var convertPlaceholdersToIcu: Boolean = convertPlaceholders + }, + projectIcuPlaceholdersEnabled = projectIcuPlaceholdersEnabled, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/util/WordCounterTest.kt b/backend/data/src/test/kotlin/io/tolgee/util/WordCounterTest.kt index cdfbb4408c..8a417da17c 100644 --- a/backend/data/src/test/kotlin/io/tolgee/util/WordCounterTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/util/WordCounterTest.kt @@ -4,15 +4,6 @@ import io.tolgee.testing.assertions.Assertions.assertThat import org.junit.jupiter.api.Test internal class WordCounterTest { - @Test - fun `returns ULocale from tag`() { - assertThat(WordCounter.getLocaleFromTag("cs_CZ").language).isEqualTo("cs") - assertThat(WordCounter.getLocaleFromTag("CS").language).isEqualTo("cs") - assertThat(WordCounter.getLocaleFromTag("CS-asdlks!!!laskjda").language).isEqualTo("cs") - assertThat(WordCounter.getLocaleFromTag("lksad(())))aldka----íáýí::").language).isEqualTo("lksad") - assertThat(WordCounter.getLocaleFromTag("").language).isEqualTo("en") - } - @Test fun `counts words correctly`() { assertThat(WordCounter.countWords("你好,這是一個繁體中文文本。", "zh-Hant")).isEqualTo(6) diff --git a/backend/data/src/test/kotlin/io/tolgee/util/exportTestUtil.kt b/backend/data/src/test/kotlin/io/tolgee/util/exportTestUtil.kt new file mode 100644 index 0000000000..558698c321 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/util/exportTestUtil.kt @@ -0,0 +1,66 @@ +package io.tolgee.util + +import io.tolgee.service.export.dataProvider.ExportKeyView +import io.tolgee.service.export.dataProvider.ExportTranslationView + +fun buildExportTranslation( + languageTag: String, + keyName: String, + text: String?, + fn: (ExportTranslationView.() -> Unit)? = null, +): ExportTranslationView { + val translation = + ExportTranslationView( + id = null, + text = text, + key = ExportKeyView(name = keyName), + languageTag = languageTag, + ) + fn?.invoke(translation) + return translation +} + +fun buildExportTranslationList(fn: BuildExportTranslationListContext.() -> Unit): BuildExportTranslationListContext { + val context = BuildExportTranslationListContext() + fn(context) + return context +} + +class BuildExportTranslationListContext( + val baseTranslations: MutableList = mutableListOf(), + val translations: MutableList = mutableListOf(), + private val baseLanguageTag: String = "en", +) { + fun add( + languageTag: String, + keyName: String, + text: String?, + baseText: String? = null, + fn: (BuildExportTranslationContext.() -> Unit)? = null, + ): BuildExportTranslationListContext { + val translation = buildExportTranslation(languageTag, keyName, text) + val baseTranslation = + ExportTranslationView( + id = null, + text = baseText, + key = translation.key, + languageTag = baseLanguageTag, + ) + baseTranslations.add(baseTranslation) + translations.add(translation) + val context = + BuildExportTranslationContext(translation, baseTranslation, baseTranslations, translations, baseLanguageTag) + fn?.invoke(context) + return this + } +} + +class BuildExportTranslationContext( + val translation: ExportTranslationView, + val baseTranslation: ExportTranslationView, + private val baseTranslations: MutableList, + private val translations: MutableList, + private val baseLanguageTag: String, +) { + val key get() = translation.key +} diff --git a/backend/data/src/test/kotlin/io/tolgee/util/importTestUtil.kt b/backend/data/src/test/kotlin/io/tolgee/util/importTestUtil.kt new file mode 100644 index 0000000000..be818ba40b --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/util/importTestUtil.kt @@ -0,0 +1,140 @@ +package io.tolgee.util + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.tolgee.formats.BaseIcuMessageConvertor +import io.tolgee.formats.NoOpFromIcuParamConvertor +import io.tolgee.formats.optimizePossiblePlural +import io.tolgee.model.dataImport.ImportKey +import io.tolgee.model.dataImport.ImportTranslation +import io.tolgee.service.dataImport.processors.FileProcessorContext +import io.tolgee.testing.assert + +fun FileProcessorContext.assertTranslations( + language: String, + key: String, +): List { + val translations = this.translations[key] ?: throw AssertionError("Translation with key $key not found") + translations.filter { it.language.name == language }.let { translationsOfKey -> + if (translationsOfKey.isEmpty()) { + throw AssertionError("Translation with key $key and language $language not found") + } + return translationsOfKey.map { ImportTranslationInContextAssertions(this, it, key) } + } +} + +fun List.assertSize(size: Int): List { + this.size.assert.isEqualTo(size) + return this +} + +fun List.assertSinglePlural( + fn: ImportTranslationInContextAssertions.() -> Unit, +): ImportTranslationInContextAssertions { + val filtered = this.filter { it.getPossiblePlural().isPlural() } + filtered.assertSize(1) + filtered[0].isPlural(true) + fn(filtered[0]) + return filtered[0] +} + +fun List.assertSingle( + fn: ImportTranslationInContextAssertions.() -> Unit, +): ImportTranslationInContextAssertions { + this.assertSize(1) + fn(this[0]) + return this[0] +} + +fun List.assertAllSame( + fn: ImportTranslationInContextAssertions.() -> Unit, +): ImportTranslationInContextAssertions { + if (this.map { it.translation.text }.toSet().size == 1) { + fn(this[0]) + return this[0] + } + throw AssertionError("Not all translations are the same") +} + +fun List.assertMultiple( + fn: List.() -> Unit = {}, +): List { + this.assert.hasSizeGreaterThan(1) + fn(this) + return this +} + +fun FileProcessorContext.assertLanguagesCount(languagesCount: Int): FileProcessorContext { + this.languages.size.assert.isEqualTo(languagesCount) + return this +} + +fun ImportTranslationInContextAssertions.hasKeyDescription(description: String) { + val keyMeta = + this.fileProcessorContext.keys[keyName]?.keyMeta + ?: throw AssertionError("Key meta not found") + keyMeta.description.assert.isEqualTo(description) +} + +fun ImportTranslationInContextAssertions.isPlural(isPlural: Boolean = true) { + this.translation.isPlural.assert.isEqualTo(isPlural) +} + +data class ImportTranslationInContextAssertions( + val fileProcessorContext: FileProcessorContext, + val translation: ImportTranslation, + val keyName: String, +) { + fun hasText(text: String): ImportTranslationInContextAssertions { + this.translation.text.assert.isEqualTo(text) + return this + } + + private fun assertTextNotNull(): ImportTranslationInContextAssertions { + this.translation.text ?: throw AssertionError("Text is null") + return this + } + + fun getPossiblePlural() = + this.translation.text!!.let { + BaseIcuMessageConvertor( + it, + NoOpFromIcuParamConvertor(), + ).convert() + } + + fun assertHasExactPluralForms(forms: Set): ImportTranslationInContextAssertions { + this.getPossiblePlural().formsResult!!.keys.assert.isEqualTo(forms) + return this + } + + fun isPluralOptimized(): ImportTranslationInContextAssertions { + assertTextNotNull() + optimizePossiblePlural(this.translation.text!!).assert.isEqualTo(this.translation.text) + return this + } +} + +fun FileProcessorContext.assertKey( + keyName: String, + fn: ImportKey.() -> Unit, +): ImportKey { + val key = this.keys[keyName] ?: throw AssertionError("Key $keyName not found") + fn(key) + return key +} + +val ImportKey.custom: Map? + get() = this.keyMeta?.custom + +fun ImportKey.customEquals(expected: String) { + val mapper = jacksonObjectMapper() + val writer = mapper.writerWithDefaultPrettyPrinter() + val expectedObject = mapper.readValue(expected) + val expectedString = writer.writeValueAsString(expectedObject) + val actual = writer.writeValueAsString(custom) + actual.assert.isEqualTo(expectedString) +} + +val ImportKey.description: String? + get() = this.keyMeta?.description diff --git a/backend/data/src/test/resources/import/android/strings.xml b/backend/data/src/test/resources/import/android/strings.xml new file mode 100644 index 0000000000..43f8d8e468 --- /dev/null +++ b/backend/data/src/test/resources/import/android/strings.xml @@ -0,0 +1,26 @@ + + Tolgee test + + %d dog + %d dogs + + + First item + Second item + + + Hello! + + + Hello! + + + Hello! + %d + + Dont'translate this + + + %d %4$s %.2f %e %+d + + diff --git a/backend/data/src/test/resources/import/android/strings_params_everywhere.xml b/backend/data/src/test/resources/import/android/strings_params_everywhere.xml new file mode 100644 index 0000000000..80dc298ce5 --- /dev/null +++ b/backend/data/src/test/resources/import/android/strings_params_everywhere.xml @@ -0,0 +1,13 @@ + + + %d dog %s {escape} + %d dogs %s + + + First item %d {escape} + Second item %d + + + %d %4$s %.2f %e %+d {escape} + + diff --git a/backend/data/src/test/resources/import/apple/Localizable.strings b/backend/data/src/test/resources/import/apple/Localizable.strings new file mode 100644 index 0000000000..ec1697dafa --- /dev/null +++ b/backend/data/src/test/resources/import/apple/Localizable.strings @@ -0,0 +1,24 @@ +"welcome_header" = "Hello, %@"; + +/* +Welcome header comment +it's a multiline comment +*\/ + +I cannot trick you! + +\*/ +"welcome_sub_header" = "Hello, %s"; // this is an inline comment\n dada +"another key" = "Dies ist ein weiterer Schlüssel."; +"another key \" with escaping" = "Dies ist ein weiterer \" Schlüssel."; + +// this is a comment with escaped \/\/ haha \ +Escaping is fun! +"another key \\ with escaping 2" = "Dies ist ein weiterer \\ Schlüssel."; +"another key with escaping 3\\" = "Dies ist ein weiterer Schlüssel\\"; +"another key + + multiline" = "Dies ist ein weiterer + +Schlüssel."; + diff --git a/backend/data/src/test/resources/import/apple/Localizable_params.strings b/backend/data/src/test/resources/import/apple/Localizable_params.strings new file mode 100644 index 0000000000..eef784da90 --- /dev/null +++ b/backend/data/src/test/resources/import/apple/Localizable_params.strings @@ -0,0 +1 @@ +"welcome_header" = "Hello, %@ {meto}"; diff --git a/backend/data/src/test/resources/import/apple/Localizable_params.stringsdict b/backend/data/src/test/resources/import/apple/Localizable_params.stringsdict new file mode 100644 index 0000000000..b6bda565c0 --- /dev/null +++ b/backend/data/src/test/resources/import/apple/Localizable_params.stringsdict @@ -0,0 +1,22 @@ + + + + + what-a-key-plural + + NSStringLocalizedFormatKey + %#@format@ + format + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + Peter has %lld dog {meto} + other + Peter hase %lld dogs {meto} + + + + diff --git a/backend/data/src/test/resources/import/apple/cs.xliff b/backend/data/src/test/resources/import/apple/cs.xliff new file mode 100644 index 0000000000..66ce7c3e00 --- /dev/null +++ b/backend/data/src/test/resources/import/apple/cs.xliff @@ -0,0 +1,106 @@ + + + +
+ +
+ + + Dogs %lld + The count of dogs in the app + + + Order %lld + No comment provided by engineer. + + + Hello! + Ahoj! + Localizable.strings + Localization test + Created by Jan Cizmar on 06.02.2024. + + + label + This is just random label + + +
+ +
+ +
+ + + %#@dog@ + + + + %lld dogs here + + + + %lld dogs here + + + + One dog is here! + + + + %lld dogs here + + + + No dogs here! + + + + %#@Ticket@ + + + + Order %lld Tickets + + + + Order %lld Tickets + + + + Order %lld Ticket + + + + Order %lld Tickets + + + + Order %lld Ticket + + + +
+ +
+ +
+ + + Localization test + Bundle name + + +
+ +
+ +
+ + + menu + + + +
+
diff --git a/backend/data/src/test/resources/import/apple/en_xcstrings.xliff b/backend/data/src/test/resources/import/apple/en_xcstrings.xliff new file mode 100644 index 0000000000..9d7499264a --- /dev/null +++ b/backend/data/src/test/resources/import/apple/en_xcstrings.xliff @@ -0,0 +1,37 @@ + + + +
+ +
+ + + apple-xliff-localization-test + apple-xliff-localization-test + Bundle name + + +
+ +
+ +
+ + + One dog + One dog + + + + %lld dogs + %lld dogs + + + + I am normal key! + I am normal key! + + + +
+
diff --git a/backend/data/src/test/resources/import/apple/example.stringsdict b/backend/data/src/test/resources/import/apple/example.stringsdict new file mode 100644 index 0000000000..7b5252d01f --- /dev/null +++ b/backend/data/src/test/resources/import/apple/example.stringsdict @@ -0,0 +1,40 @@ + + + + + what-a-key-plural + + NSStringLocalizedFormatKey + %#@format@ + format + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + Peter has %lld dog + other + Peter hase %lld dogs + + + + + what-a-key-plural-2 + + NSStringLocalizedFormatKey + %#@format@ + format + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + la + one + Lucy has %la {dog} + other + Lucy has %la {dogs} + + + + diff --git a/backend/data/src/test/resources/import/apple/params_everywhere_cs.xliff b/backend/data/src/test/resources/import/apple/params_everywhere_cs.xliff new file mode 100644 index 0000000000..823a1fec93 --- /dev/null +++ b/backend/data/src/test/resources/import/apple/params_everywhere_cs.xliff @@ -0,0 +1,48 @@ + + + +
+ +
+ + + Dogs %lld + The count of dogs in the app + + + Hi %lld {icuParam} + + +
+ +
+ +
+ + + %#@dog@ + + + + %lld dogs here %@ {icuParam} + + + + %lld dogs here %@ {icuParam} + + + + One dog is here %@ {icuParam}! + + + + %lld dogs here %@ {icuParam} + + + + No dogs here %@ {icuParam}! + + + +
+
diff --git a/backend/data/src/test/resources/import/example.properties b/backend/data/src/test/resources/import/example.properties deleted file mode 100644 index a16343b7fe..0000000000 --- a/backend/data/src/test/resources/import/example.properties +++ /dev/null @@ -1,7 +0,0 @@ -Localization_tools=Lokaliza?n nstroje - -Welcome=Herzlich Wilkommen -# This is a comment -Register=Veuillez vous enregistrer \ - sur la page suivante. -Cleanup=Veeg uw\n voeten\n voordat u binnengaat. diff --git a/backend/data/src/test/resources/import/flutter/app_en.arb b/backend/data/src/test/resources/import/flutter/app_en.arb new file mode 100644 index 0000000000..2de8ab109c --- /dev/null +++ b/backend/data/src/test/resources/import/flutter/app_en.arb @@ -0,0 +1,20 @@ +{ + "@@locale": "en", + "helloWorld": "Hello World!", + "@helloWorld": { + "description": "The conventional newborn programmer greeting" + }, + "dogsCount": "I have {count, plural, one {one dog} other {{count} dogs}}.", + "@dogsCount": { + "description": "The conventional newborn programmer greeting", + "placeholders": { + "count": { + "type": "int", + "optionalParameters": { + "decimalDigits": 1 + } + } + } + }, + "simpleDogCount": "Dogs count: {count}" +} diff --git a/backend/data/src/test/resources/import/flutter/app_en_params.arb b/backend/data/src/test/resources/import/flutter/app_en_params.arb new file mode 100644 index 0000000000..3e464480c9 --- /dev/null +++ b/backend/data/src/test/resources/import/flutter/app_en_params.arb @@ -0,0 +1,5 @@ +{ + "@@locale": "en", + "helloWorld": "Hello World! {name}", + "dogsCount": "I have {count, plural, one {one dog} other {{count} dogs}}." +} diff --git a/backend/data/src/test/resources/import/json/example.json b/backend/data/src/test/resources/import/json/example.json new file mode 100644 index 0000000000..6b9d61c910 --- /dev/null +++ b/backend/data/src/test/resources/import/json/example.json @@ -0,0 +1,24 @@ +{ + "common": { + "save": "Save" + }, + "array": [ + "one", + "two", + "three" + ], + "common.save": "This is conflict", + "array[0]": "This is conflict", + "a": { + "b": { + "c": "This is nested hard.", + "d": [ + "one", + "two", + "three" + ] + } + }, + "null": null, + "boolean": true +} diff --git a/backend/data/src/test/resources/import/json/example_params.json b/backend/data/src/test/resources/import/json/example_params.json new file mode 100644 index 0000000000..3b00abbf01 --- /dev/null +++ b/backend/data/src/test/resources/import/json/example_params.json @@ -0,0 +1,4 @@ +{ + "key": "Hello {icuPara}", + "plural": "Hello {count, plural, one {one # {icuParam}} other {other {icuParam}}}" +} diff --git a/backend/data/src/test/resources/import/json/example_root_array.json b/backend/data/src/test/resources/import/json/example_root_array.json new file mode 100644 index 0000000000..f470a34e38 --- /dev/null +++ b/backend/data/src/test/resources/import/json/example_root_array.json @@ -0,0 +1,4 @@ +[ + "item 1", + "item 2" +] diff --git a/backend/data/src/test/resources/import/json/plural.json b/backend/data/src/test/resources/import/json/plural.json new file mode 100644 index 0000000000..8a6735c381 --- /dev/null +++ b/backend/data/src/test/resources/import/json/plural.json @@ -0,0 +1,3 @@ +{ + "plural": "{count, plural, one {# day} other {# days}}" +} diff --git a/backend/data/src/test/resources/import/po/example_params.po b/backend/data/src/test/resources/import/po/example_params.po new file mode 100644 index 0000000000..cfb5effa61 --- /dev/null +++ b/backend/data/src/test/resources/import/po/example_params.po @@ -0,0 +1,17 @@ +msgid "" +msgstr "" +"Project-Id-Version: Tolgee 1.0.1\n" +"Report-Msgid-Bugs-To: info@tolgee.io \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "hello" +msgstr "Hi %d {icuParam}" + +msgid "%d page read." +msgid_plural "%d pages read." +msgstr[0] "Hallo %d {icuParam}" +msgstr[1] "Hallo %d {icuParam}" diff --git a/backend/data/src/test/resources/import/po/example_raw_escaping.po b/backend/data/src/test/resources/import/po/example_raw_escaping.po new file mode 100644 index 0000000000..903a4c2956 --- /dev/null +++ b/backend/data/src/test/resources/import/po/example_raw_escaping.po @@ -0,0 +1,17 @@ +msgid "" +msgstr "" +"Project-Id-Version: Tolgee 1.0.1\n" +"Report-Msgid-Bugs-To: info@tolgee.io \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "Localization tools" +msgstr "Lokalizační %s {this} {} 'is not icu' nástroje" + +msgid "%d page read." +msgid_plural "%d pages read." +msgstr[0] "Eine Seite {this} {} 'is not icu' %s gelesen wurde." +msgstr[1] "%d Seiten gelesen wurden." diff --git a/backend/data/src/test/resources/import/properties/example.properties b/backend/data/src/test/resources/import/properties/example.properties new file mode 100644 index 0000000000..ca0a7d97f3 --- /dev/null +++ b/backend/data/src/test/resources/import/properties/example.properties @@ -0,0 +1,12 @@ +key1=Duplicated +key1=Duplicated 2 " +escaping\ test=Escaping = \\ = \n \ + new line \n = = " +array=1, 2, 3 +with.dots.s=Hey +number=1 +boolean=true +# A commnet +with_commnet=with comment +! A commnet +with_commnet_2=with comment diff --git a/backend/data/src/test/resources/import/properties/example_params.properties b/backend/data/src/test/resources/import/properties/example_params.properties new file mode 100644 index 0000000000..fed01519bc --- /dev/null +++ b/backend/data/src/test/resources/import/properties/example_params.properties @@ -0,0 +1,2 @@ +key = Hello {icuPara} '{escaped}', +plural = Hello {count, plural, one {one # {icuParam}} other {other {icuParam} '{escaped}'}} diff --git a/backend/data/src/test/resources/import/xliff/example_params.xliff b/backend/data/src/test/resources/import/xliff/example_params.xliff new file mode 100644 index 0000000000..0376b7c186 --- /dev/null +++ b/backend/data/src/test/resources/import/xliff/example_params.xliff @@ -0,0 +1,13 @@ + + + + + + Hello {icuPara} + + + Hello {count, plural, one {one # {icuParam}} other {other {icuParam}}} + + + + diff --git a/backend/data/src/test/resources/import/xliff/preserving-spaces.xliff b/backend/data/src/test/resources/import/xliff/preserving-spaces.xliff new file mode 100644 index 0000000000..8689af36bd --- /dev/null +++ b/backend/data/src/test/resources/import/xliff/preserving-spaces.xliff @@ -0,0 +1,24 @@ + + + + + + Back + + + + + + + Back + + + + + + + Back + + + + diff --git a/backend/app/src/test/resources/xliff/xliff-core-1.2-transitional.xsd b/backend/data/src/test/resources/import/xliff/xliff-core-1.2-transitional.xsd similarity index 100% rename from backend/app/src/test/resources/xliff/xliff-core-1.2-transitional.xsd rename to backend/data/src/test/resources/import/xliff/xliff-core-1.2-transitional.xsd diff --git a/backend/testing/src/main/kotlin/io/tolgee/testing/assert.kt b/backend/testing/src/main/kotlin/io/tolgee/testing/assert.kt index b32a1e3148..bc8d31dc42 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/testing/assert.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/testing/assert.kt @@ -9,6 +9,7 @@ import org.assertj.core.api.AbstractIntegerAssert import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.AbstractStringAssert import org.assertj.core.api.IterableAssert +import org.assertj.core.api.MapAssert import org.assertj.core.api.ObjectArrayAssert import org.assertj.core.api.ObjectAssert import java.math.BigDecimal @@ -17,6 +18,9 @@ import java.util.* inline val T.assert: ObjectAssert get() = Assertions.assertThat(this) +inline val Map.assert: MapAssert + get() = Assertions.assertThat(this) + inline val Int?.assert: AbstractIntegerAssert<*> get() = Assertions.assertThat(this) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index bdff73f39f..e7795928c0 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -6,6 +6,7 @@ import Chainable = Cypress.Chainable; type AccountType = components['schemas']['PrivateUserAccountModel']['accountType']; +type CreateProjectRequest = components['schemas']['CreateProjectRequest']; type ImportKeysItemDto = components['schemas']['ImportKeysItemDto']; @@ -121,10 +122,9 @@ export const getDefaultOrganization = () => { }); }; -export const createProject = (createProjectDto: { - name: string; - languages: Partial[]; -}): Chainable> => { +export const createProject = ( + createProjectDto: Partial +): Chainable> => { const create = () => { return getDefaultOrganization().then((org) => { return v2apiFetch('projects', { @@ -156,13 +156,18 @@ export const createTestProject = () => ], }); +type CreateKeyOptions = { + isPlural?: boolean; +}; + export const createKey = ( projectId, key: string, - translations: { [lang: string]: string } + translations: { [lang: string]: string }, + options?: CreateKeyOptions ): Chainable => v2apiFetch(`projects/${projectId}/keys`, { - body: { name: key, translations }, + body: { ...options, name: key, translations }, method: 'POST', }).then((r) => { return r.body; diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 1b5195911a..d24e0ee9bd 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -51,7 +51,8 @@ export const languagePermissionsData = generateTestDataObject( 'language-permissions' ); -export const contentDelivery = generateTestDataObject('content-delivery'); +export const contentDeliveryTestData = + generateTestDataObject('content-delivery'); export const generateExampleKeys = ( projectId: number, diff --git a/e2e/cypress/common/comments.ts b/e2e/cypress/common/comments.ts index c02c62240b..676d0b8e18 100644 --- a/e2e/cypress/common/comments.ts +++ b/e2e/cypress/common/comments.ts @@ -1,19 +1,14 @@ import { waitForGlobalLoading } from './loading'; -import { confirmStandard } from './shared'; +import { confirmStandard, gcyAdvanced } from './shared'; -export function commentsButton(index: number, language: string) { - return cy - .gcy('translations-row') - .eq(index) +export function commentsButton(key: string, language: string) { + return gcyAdvanced({ value: 'translations-table-cell', language, key }) .trigger('mouseover') - .findDcy('translations-table-cell-language') - .contains(language) - .closestDcy('translations-table-cell') .findDcy('translations-cell-comments-button'); } -export function createComment(text: string, index: number, lang: string) { - commentsButton(index, lang).click(); +export function createComment(text: string, key: string, lang: string) { + commentsButton(key, lang).click(); waitForGlobalLoading(); cy.gcy('translations-comments-input').type(text).type('{enter}'); waitForGlobalLoading(); diff --git a/e2e/cypress/common/export.ts b/e2e/cypress/common/export.ts index 917cfaabf1..79a1c430e9 100644 --- a/e2e/cypress/common/export.ts +++ b/e2e/cypress/common/export.ts @@ -1,8 +1,8 @@ import { createKey, createProject, login } from './apiCalls/common'; import { HOST } from './constants'; import { ProjectDTO } from '../../../webapp/src/service/response.types'; -import Chainable = Cypress.Chainable; import { dismissMenu } from './shared'; +import Chainable = Cypress.Chainable; export function createExportableProject(): Chainable { return login().then(() => { @@ -51,5 +51,146 @@ export const exportToggleLanguage = (lang: string) => { export const exportSelectFormat = (format: string) => { cy.gcy('export-format-selector').click(); cy.gcy('export-format-selector-item').contains(format).click(); - dismissMenu(); +}; + +export const testExportFormats = ( + interceptFn: () => ReturnType, + submitFn: () => void, + clearCheckboxesAfter: boolean, + afterFn: (test: FormatTest) => void +) => { + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'JSON', + expectedParams: { + format: 'JSON', + supportArrays: false, + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Structured JSON', + expectedParams: { + format: 'JSON', + structureDelimiter: '.', + supportArrays: false, + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Structured JSON', + clickCheckboxes: ['export-support_arrays-selector'] as DataCy.Value[], + expectedParams: { + format: 'JSON', + structureDelimiter: '.', + supportArrays: true, + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'XLIFF', + expectedParams: { + format: 'XLIFF', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: '.properties', + expectedParams: { + format: 'PROPERTIES', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'PHP .po', + expectedParams: { + format: 'PO', + messageFormat: 'PHP_SPRINTF', + }, + }); + + // testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + // format: 'Python .po', + // expectedParams: { + // format: 'PO', + // messageFormat: 'PYTHON_SPRINTF', + // }, + // }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'C/C++ .po', + expectedParams: { + format: 'PO', + messageFormat: 'C_SPRINTF', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Apple .strings & .stringsdict', + expectedParams: { + format: 'APPLE_STRINGS_STRINGSDICT', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Apple .xliff', + expectedParams: { + format: 'APPLE_XLIFF', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Android .xml', + expectedParams: { + format: 'ANDROID_XML', + }, + }); + + testFormat(interceptFn, submitFn, clearCheckboxesAfter, afterFn, { + format: 'Flutter .arb', + expectedParams: { + format: 'FLUTTER_ARB', + }, + }); +}; + +const testFormat = ( + interceptFn: () => ReturnType, + submitFn: () => void, + clearCheckboxesAfter: boolean, + afterFn: (test: FormatTest) => void, + test: FormatTest +) => { + cy.log(`Testing format: ${test.format}`); + const paramsJson = JSON.stringify(test); + const alias = `exportFormRequest_${paramsJson}`; + interceptFn().as(alias); + exportSelectFormat(test.format); + clickCheckboxes(test); + submitFn(); + cy.wait(`@${alias}`).then((interception) => { + expect(interception.request.body).to.include(test.expectedParams); + }); + if (clearCheckboxesAfter) { + clickCheckboxes(test); + } + afterFn(test); +}; + +function clickCheckboxes(test: FormatTest) { + if (test.clickCheckboxes) { + test.clickCheckboxes.forEach((checkbox) => { + cy.gcy(checkbox).click(); + }); + } +} + +type FormatTest = { + format: string; + clickCheckboxes?: DataCy.Value[]; + expectedParams: { + messageFormat?: string; + format: string; + structureDelimiter?: string; + supportArrays?: boolean; + }; }; diff --git a/e2e/cypress/common/permissions/translations.ts b/e2e/cypress/common/permissions/translations.ts index f16431e35f..15486845e1 100644 --- a/e2e/cypress/common/permissions/translations.ts +++ b/e2e/cypress/common/permissions/translations.ts @@ -58,7 +58,7 @@ export function testTranslations({ project, languages }: ProjectInfo) { 'be.visible' ); } - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } else if (languageAccess('translations.view', lang)) { cy.gcy('translations-table-cell-translation').contains(text).click(); cy.gcy('global-editor').should('not.exist'); @@ -86,7 +86,7 @@ export function testTranslations({ project, languages }: ProjectInfo) { if (languageAccess('translations.view', lang)) { cy.gcy('translations-state-indicator').should('be.visible'); - commentsButton(0, lang).click(); + commentsButton('key-10', lang).click(); cy.gcy('comment-text').should('be.visible'); if (scopes.includes('translation-comments.set-state')) { @@ -105,7 +105,7 @@ export function testTranslations({ project, languages }: ProjectInfo) { cy.gcy('comment-text').contains('test comment').should('be.visible'); } - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } }); } diff --git a/e2e/cypress/common/state.ts b/e2e/cypress/common/state.ts index 452e2eae2a..efe22ff7f1 100644 --- a/e2e/cypress/common/state.ts +++ b/e2e/cypress/common/state.ts @@ -8,10 +8,10 @@ export const stateColors = { export const getCell = (translationText: string) => { return cy - .gcy('translations-table-cell-translation-text') + .gcy('translation-text') .contains(translationText) .should('be.visible') - .closestDcy('translations-table-cell'); + .closestDcy('translations-table-cell-translation'); }; export const getStateIndicator = (translationText: string) => { diff --git a/e2e/cypress/common/translations.ts b/e2e/cypress/common/translations.ts index 1c1fcb6906..93644130dc 100644 --- a/e2e/cypress/common/translations.ts +++ b/e2e/cypress/common/translations.ts @@ -7,7 +7,7 @@ import { import { HOST } from './constants'; import { ProjectDTO } from '../../../webapp/src/service/response.types'; import { waitForGlobalLoading } from './loading'; -import { assertMessage, dismissMenu } from './shared'; +import { assertMessage, dismissMenu, gcyAdvanced } from './shared'; import Chainable = Cypress.Chainable; import { selectNamespace } from './namespace'; @@ -27,12 +27,23 @@ export const getCell = (value: string) => { return cy.gcy('translations-table-cell').contains(value); }; +export const getTranslationCell = (key: string, language: string) => { + return gcyAdvanced({ value: 'translations-table-cell', key, language }); +}; + +export const getPluralEditor = (variant: string) => { + return gcyAdvanced({ value: 'translation-editor', variant }).find( + '[contenteditable]' + ); +}; + type Props = { key: string; - translation?: string; + translation?: string | Record; tag?: string; namespace?: string; description?: string; + variableName?: string; }; export function createTranslation({ @@ -41,6 +52,7 @@ export function createTranslation({ tag, namespace, description, + variableName, }: Props) { waitForGlobalLoading(); cy.gcy('translations-add-button').click(); @@ -55,8 +67,19 @@ export function createTranslation({ cy.gcy('translations-tag-input').type(tag); cy.gcy('tag-autocomplete-option').contains(`Add "${tag}"`).click(); } - if (translation) { - cy.gcy('translation-create-translation-input').first().type(translation); + if (typeof translation === 'string') { + cy.gcy('translation-editor').first().type(translation); + } else if (typeof translation === 'object') { + cy.gcy('key-plural-checkbox').click(); + if (variableName) { + cy.gcy('key-plural-checkbox-expand').click(); + cy.gcy('key-plural-variable-name').type(variableName); + } + Object.entries(translation).forEach(([key, value]) => { + gcyAdvanced({ value: 'translation-editor', variant: key }) + .find('[contenteditable]') + .type(value); + }); } cy.gcy('global-form-save-button').click(); @@ -70,7 +93,9 @@ export function selectLangsInLocalstorage(projectId: number, langs: string[]) { ); } -export function translationsBeforeEach(): Chainable { +export function translationsBeforeEach( + languages?: string[] +): Chainable { return login().then(() => { return createProject({ name: 'Test', @@ -88,7 +113,7 @@ export function translationsBeforeEach(): Chainable { ], }).then((r) => { const project = r.body as ProjectDTO; - selectLangsInLocalstorage(project.id, ['en']); + selectLangsInLocalstorage(project.id, languages ?? ['en']); return visitTranslations(project.id).then(() => project); }); }); @@ -108,12 +133,11 @@ export const editCell = (oldValue: string, newValue?: string, save = true) => { if (newValue !== undefined) { // select all, delete and type new text - cy.get('.CodeMirror') + cy.gcy('global-editor') .first() - .then((editor) => { - // @ts-ignore - editor[0].CodeMirror.setValue(newValue); - }); + .find('[contenteditable]') + .clear() + .type(newValue); if (save) { getCellSaveButton().click(); diff --git a/e2e/cypress/e2e/userSettings/announcement.cy.ts b/e2e/cypress/e2e/announcement.cy.ts similarity index 93% rename from e2e/cypress/e2e/userSettings/announcement.cy.ts rename to e2e/cypress/e2e/announcement.cy.ts index 25f693e564..141305843f 100644 --- a/e2e/cypress/e2e/userSettings/announcement.cy.ts +++ b/e2e/cypress/e2e/announcement.cy.ts @@ -1,11 +1,11 @@ /// -import { HOST } from '../../common/constants'; +import { HOST } from '../common/constants'; import { createUser, forceDate, login, releaseForcedDate, -} from '../../common/apiCalls/common'; +} from '../common/apiCalls/common'; describe('Feature announcement', () => { let initalUser: string; diff --git a/e2e/cypress/e2e/formerUser.cy.ts b/e2e/cypress/e2e/formerUser.cy.ts index a303a6c644..8f99d7287b 100644 --- a/e2e/cypress/e2e/formerUser.cy.ts +++ b/e2e/cypress/e2e/formerUser.cy.ts @@ -2,6 +2,7 @@ import { formerUserTestData } from '../common/apiCalls/testData/testData'; import { HOST } from '../common/constants'; import { deleteUser, login, setTranslations } from '../common/apiCalls/common'; import { visitTranslations } from '../common/translations'; +import { gcyAdvanced } from '../common/shared'; describe('Former user', () => { let projectId: number; @@ -31,7 +32,7 @@ describe('Former user', () => { it('shows the former user in translation history', () => { visitTranslations(projectId); cy.gcy('translations-cell-comments-button').click(); - cy.gcy('translations-cell-tab-history').click(); + gcyAdvanced({ value: 'translation-panel-toggle', id: 'history' }).click(); cy.waitForDom(); cy.gcy('translation-history-item') .findDcy('auto-avatar-img') diff --git a/e2e/cypress/e2e/import/importErrors.cy.ts b/e2e/cypress/e2e/import/importErrors.cy.ts index 46c7ef110d..59dc9466a7 100644 --- a/e2e/cypress/e2e/import/importErrors.cy.ts +++ b/e2e/cypress/e2e/import/importErrors.cy.ts @@ -53,10 +53,13 @@ describe('Import errors', () => { login('franta'); visitImport(res.body.id); }); - gcy('import-file-input').attachFile({ - filePath: 'import/error.jsn', - fileName: 'error.json', - }); + cy.get('[data-cy=dropzone]').attachFile( + { + filePath: 'import/error.json.zip', + fileName: 'error.zip', + }, + { subjectType: 'drag-n-drop', force: true } + ); }); it('shows error for bad file', () => { @@ -71,13 +74,13 @@ describe('Import errors', () => { .findDcy('import-file-error-more-less-button') .click(); gcy('import-file-error') - .contains('Cannot construct instance of') + .contains("Unrecognized token 'asdlasj") .should('be.visible'); gcy('import-file-error') .findDcy('import-file-error-more-less-button') .click(); gcy('import-file-error') - .contains('Cannot construct instance of') + .contains("Unrecognized token 'asdlasj") .should('not.exist'); }); diff --git a/e2e/cypress/e2e/import/importResolving.cy.ts b/e2e/cypress/e2e/import/importResolving.cy.ts index ee602a47ae..fc177d59f5 100644 --- a/e2e/cypress/e2e/import/importResolving.cy.ts +++ b/e2e/cypress/e2e/import/importResolving.cy.ts @@ -99,23 +99,31 @@ describe('Import Resolving', () => { .click(); const assertBothExpanded = () => { - cy.contains('Hello, I am old translation').then(($text: any) => { - cy.wrap($text!.height()).should('be.greaterThan', 80); - }); - cy.contains('Hello, I am translation').then(($text: any) => { - cy.wrap($text!.height()).should('be.greaterThan', 80); - }); + cy.contains('Hello, I am old translation') + .closestDcy('import-resolution-dialog-existing-translation') + .then(($text: any) => { + cy.wrap($text!.height()).should('be.greaterThan', 80); + }); + cy.contains('Hello, I am translation') + .closestDcy('import-resolution-dialog-new-translation') + .then(($text: any) => { + cy.wrap($text!.height()).should('be.greaterThan', 80); + }); }; const assertBothCollapsed = () => { - cy.contains('Hello, I am old translation').then(($text: any) => { - cy.wrap($text!.width()).should('be.lessThan', 520); - cy.wrap($text!.height()).should('be.lessThan', 35); - }); - cy.contains('Hello, I am translation').then(($text: any) => { - cy.wrap($text!.width()).should('be.lessThan', 520); - cy.wrap($text!.height()).should('be.lessThan', 35); - }); + cy.contains('Hello, I am old translation') + .closestDcy('import-resolution-dialog-existing-translation') + .then(($text: any) => { + cy.wrap($text!.width()).should('be.lessThan', 520); + cy.wrap($text!.height()).should('be.lessThan', 70); + }); + cy.contains('Hello, I am translation') + .closestDcy('import-resolution-dialog-new-translation') + .then(($text: any) => { + cy.wrap($text!.width()).should('be.lessThan', 520); + cy.wrap($text!.height()).should('be.lessThan', 70); + }); }; assertBothCollapsed(); diff --git a/e2e/cypress/e2e/import/importSettings.cy.ts b/e2e/cypress/e2e/import/importSettings.cy.ts new file mode 100644 index 0000000000..d18a47efcb --- /dev/null +++ b/e2e/cypress/e2e/import/importSettings.cy.ts @@ -0,0 +1,122 @@ +import 'cypress-file-upload'; +import { gcy } from '../../common/shared'; +import { + assertInResultDialog, + getLanguageRow, + getShowDataDialog, + visitImport, +} from '../../common/import'; +import { importTestData } from '../../common/apiCalls/testData/testData'; +import { login } from '../../common/apiCalls/common'; + +describe('Import Adding files', () => { + beforeEach(() => { + importTestData.clean(); + + importTestData.generateBase().then((project) => { + login('franta'); + visitImport(project.body.id); + }); + }); + + it('applies settings when file uploaded (initially without conversion)', () => { + interceptSettingsAndAssertRequest( + () => { + gcy('import-convert-placeholders-to-icu-checkbox').click(); + }, + { + convertPlaceholdersToIcu: false, + overrideKeyDescriptions: false, + } + ); + + gcy('import-file-input').attachFile(['import/po/placeholders.po']); + gcy('import-result-total-count-cell', { timeout: 60000 }).should('exist'); + + goToResult(); + assertInResultDialog('Willkommen zurück, %1$s!'); + + cy.get('body').type('{esc}'); + + interceptSettingsAndAssertRequest( + () => { + gcy('import-convert-placeholders-to-icu-checkbox').click(); + }, + { + convertPlaceholdersToIcu: true, + overrideKeyDescriptions: false, + } + ); + + goToResult(); + assertInResultDialog('Willkommen zurück, 0! Dein letzter Besuch war am 1'); + }); + + it('applies settings when file uploaded with settings (initially with conversion)', () => { + gcy('import-file-input').attachFile(['import/po/placeholders.po']); + + gcy('import-result-total-count-cell', { timeout: 60000 }).should('exist'); + goToResult(); + assertInResultDialog('Willkommen zurück, 0! Dein letzter Besuch war am 1'); + + cy.get('body').type('{esc}'); + + interceptSettingsAndAssertRequest( + () => { + gcy('import-convert-placeholders-to-icu-checkbox').click(); + }, + { + convertPlaceholdersToIcu: false, + overrideKeyDescriptions: false, + } + ); + goToResult(); + assertInResultDialog('Willkommen zurück, %1$s!'); + }); + + it('applies the overrideKeyDescriptions', () => { + interceptSettingsAndAssertRequest( + () => { + gcy('import-override-key-descriptions-checkbox').click(); + }, + { + convertPlaceholdersToIcu: true, + overrideKeyDescriptions: true, + } + ); + }); + + after(() => { + importTestData.clean(); + }); +}); + +const interceptSettingsAndAssertRequest = ( + fn: () => void, + body: { + convertPlaceholdersToIcu: boolean; + overrideKeyDescriptions: boolean; + } +) => { + cy.intercept('PUT', '**/import-settings').as('importSettings'); + fn(); + + cy.wait('@importSettings').then((interception) => { + assert.isNotNull(interception.request.body); + expect(interception.request.body).to.have.property( + 'convertPlaceholdersToIcu', + body.convertPlaceholdersToIcu + ); + expect(interception.request.body).to.have.property( + 'overrideKeyDescriptions', + body.overrideKeyDescriptions + ); + }); +}; + +const goToResult = () => { + getLanguageRow('placeholders.po (de)') + .findDcy('import-result-show-all-translations-button') + .click(); + getShowDataDialog().should('be.visible'); +}; diff --git a/e2e/cypress/e2e/projects/contentDelivery.cy.ts b/e2e/cypress/e2e/projects/contentDelivery.cy.ts index ab6bd26257..67db47845c 100644 --- a/e2e/cypress/e2e/projects/contentDelivery.cy.ts +++ b/e2e/cypress/e2e/projects/contentDelivery.cy.ts @@ -1,22 +1,25 @@ import { assertMessage, confirmStandard, + dismissMenu, + gcy, gcyAdvanced, visitProjectDeveloperContentDelivery, } from '../../common/shared'; -import { contentDelivery } from '../../common/apiCalls/testData/testData'; +import { contentDeliveryTestData } from '../../common/apiCalls/testData/testData'; import { login, setContentStorageBypass } from '../../common/apiCalls/common'; import { waitForGlobalLoading } from '../../common/loading'; import { setFeature } from '../../common/features'; +import { testExportFormats } from '../../common/export'; describe('Content delivery', () => { let projectId: number; beforeEach(() => { setFeature('MULTIPLE_CONTENT_DELIVERY_CONFIGS', true); setContentStorageBypass(true); - contentDelivery.clean(); - contentDelivery.generateStandard().then((response) => { - login(); + contentDeliveryTestData.clean(); + contentDeliveryTestData.generateStandard().then((response) => { + login('test_username'); projectId = response.body.projects[0].id; visitProjectDeveloperContentDelivery(projectId); }); @@ -25,6 +28,7 @@ describe('Content delivery', () => { afterEach(() => { setFeature('MULTIPLE_CONTENT_DELIVERY_CONFIGS', true); setContentStorageBypass(false); + contentDeliveryTestData.clean(); }); it('publishes content manually', () => { @@ -37,11 +41,7 @@ describe('Content delivery', () => { it('creates content delivery', () => { const name = 'Crazy content delivery'; - cy.gcy('content-delivery-add-button').click(); - cy.gcy('content-delivery-form-name').type(name); - cy.gcy('content-delivery-storage-selector').click(); - cy.gcy('content-delivery-storage-selector-item').contains('Azure').click(); - cy.gcy('content-delivery-form-save').click(); + createAzureContentDeliveryConfig(name); waitForGlobalLoading(); gcyAdvanced({ value: 'content-delivery-list-item', name: 'Azure' }).should( 'be.visible' @@ -49,6 +49,59 @@ describe('Content delivery', () => { assertMessage('Content delivery successfully created!'); }); + it('creates content delivery config with proper export params ', () => { + testExportFormats( + () => { + cy.gcy('content-delivery-add-button').click(); + fillContentDeliveryConfigForm('CD'); + return cy.intercept('POST', '/v2/projects/**/content-delivery-configs'); + }, + () => { + cy.gcy('content-delivery-form-save').click(); + }, + false, + () => {} + ); + }); + + it('updates content delivery config with proper export params ', () => { + function openEditDialog() { + gcyAdvanced({ value: 'content-delivery-list-item', name: 'Azure' }) + .findDcy('content-delivery-item-type') + .contains('Manual'); + gcyAdvanced({ value: 'content-delivery-list-item', name: 'Azure' }) + .findDcy('content-delivery-item-edit') + .click(); + } + + testExportFormats( + () => { + openEditDialog(); + fillContentDeliveryConfigForm('Azure'); + return cy.intercept( + 'PUT', + '/v2/projects/**/content-delivery-configs/**' + ); + }, + () => { + cy.gcy('content-delivery-form-save').click(); + }, + false, + (test) => { + // we need to also test that the saved props are correctly dsplayed, since the logic is not + // super simple + openEditDialog(); + if (test.expectedParams.supportArrays) { + cy.gcy('export-support_arrays-selector') + .find('input') + .should('be.checked'); + } + gcy('export-format-selector').should('contain', test.format); + dismissMenu(); + } + ); + }); + it('updates existing content delivery', () => { const name = 'Azure edited'; gcyAdvanced({ value: 'content-delivery-list-item', name: 'Azure' }) @@ -71,7 +124,7 @@ describe('Content delivery', () => { }); it('deletes content delivery', () => { - deleteDelivery('Azure'); + deleteContentDeliveryConfig('Azure'); }); it('shows info if feature not enabled', () => { @@ -81,14 +134,14 @@ describe('Content delivery', () => { 'be.visible' ); - deleteDelivery('Azure'); - deleteDelivery('S3'); + deleteContentDeliveryConfig('Azure'); + deleteContentDeliveryConfig('S3'); cy.contains('Only single content delivery configuration enabled'); cy.gcy('content-delivery-add-button').should('be.disabled'); }); - function deleteDelivery(name: string) { + function deleteContentDeliveryConfig(name: string) { gcyAdvanced({ value: 'content-delivery-list-item', name }) .findDcy('content-delivery-item-edit') .click(); @@ -99,3 +152,15 @@ describe('Content delivery', () => { cy.gcy('content-delivery-list-item').contains(name).should('not.exist'); } }); + +function fillContentDeliveryConfigForm(name: string) { + cy.gcy('content-delivery-form-name').clear().type(name); + cy.gcy('content-delivery-storage-selector').click(); + cy.gcy('content-delivery-storage-selector-item').contains('Azure').click(); +} + +function createAzureContentDeliveryConfig(name: string) { + cy.gcy('content-delivery-add-button').click(); + fillContentDeliveryConfigForm(name); + cy.gcy('content-delivery-form-save').click(); +} diff --git a/e2e/cypress/e2e/projects/contentStorage.cy.ts b/e2e/cypress/e2e/projects/contentStorage.cy.ts index 573b062b58..6b6db529f1 100644 --- a/e2e/cypress/e2e/projects/contentStorage.cy.ts +++ b/e2e/cypress/e2e/projects/contentStorage.cy.ts @@ -4,7 +4,7 @@ import { gcyAdvanced, visitProjectDeveloperStorage, } from '../../common/shared'; -import { contentDelivery } from '../../common/apiCalls/testData/testData'; +import { contentDeliveryTestData } from '../../common/apiCalls/testData/testData'; import { login, setContentStorageBypass } from '../../common/apiCalls/common'; import { waitForGlobalLoading } from '../../common/loading'; import { setFeature } from '../../common/features'; @@ -13,9 +13,9 @@ describe('Content storage', () => { beforeEach(() => { setFeature('PROJECT_LEVEL_CONTENT_STORAGES', true); setContentStorageBypass(true); - contentDelivery.clean(); - contentDelivery.generateStandard().then((response) => { - login(); + contentDeliveryTestData.clean(); + contentDeliveryTestData.generateStandard().then((response) => { + login('test_username'); const projectId = response.body.projects[0].id; visitProjectDeveloperStorage(projectId); }); diff --git a/e2e/cypress/e2e/projects/export.cy.ts b/e2e/cypress/e2e/projects/export.cy.ts deleted file mode 100644 index 07309dfa1b..0000000000 --- a/e2e/cypress/e2e/projects/export.cy.ts +++ /dev/null @@ -1,60 +0,0 @@ -import 'cypress-file-upload'; -import { createKey } from '../../common/apiCalls/common'; -import { - createExportableProject, - exportSelectFormat, - exportToggleLanguage, - visitExport, -} from '../../common/export'; - -describe('Projects Basics', () => { - const downloadsFolder = Cypress.config('downloadsFolder'); - - beforeEach(() => { - createExportableProject().then((p) => { - createKey(p.id, `test.test`, { - en: `Test english`, - cs: `Test czech`, - }); - visitExport(p.id); - cy.gcy('export-submit-button').should('be.visible'); - }); - }); - - it('exports all to zip by default', () => { - cy.gcy('export-submit-button').click(); - cy.verifyDownload('Test project.zip'); - }); - - it('exports one language to json', () => { - exportToggleLanguage('Česky'); - - cy.gcy('export-submit-button').click(); - - cy.readFile(downloadsFolder + '/en.json').should('deep.equal', { - 'test.test': 'Test english', - }); - }); - - it('exports with nested structure', () => { - exportToggleLanguage('English'); - - cy.gcy('export-nested-selector').click(); - cy.gcy('export-submit-button').click(); - cy.verifyDownload('cs.json'); - - cy.readFile(downloadsFolder + '/cs.json') - .its('test') - .its('test') - .should('eq', 'Test czech'); - }); - - it('exports one language to xliff', () => { - exportToggleLanguage('Česky'); - - exportSelectFormat('XLIFF'); - - cy.gcy('export-submit-button').click(); - cy.verifyDownload('en.xliff'); - }); -}); diff --git a/e2e/cypress/e2e/projects/export/export.cy.ts b/e2e/cypress/e2e/projects/export/export.cy.ts new file mode 100644 index 0000000000..25bb6d8d57 --- /dev/null +++ b/e2e/cypress/e2e/projects/export/export.cy.ts @@ -0,0 +1,98 @@ +import 'cypress-file-upload'; +import { createKey, deleteProject } from '../../../common/apiCalls/common'; +import { + createExportableProject, + exportSelectFormat, + exportToggleLanguage, + visitExport, +} from '../../../common/export'; + +describe('Export Basics', () => { + const downloadsFolder = Cypress.config('downloadsFolder'); + + let projectId: number; + + beforeEach(() => { + createExportableProject().then((p) => { + createKey(p.id, `test.test`, { + en: `Test english`, + cs: `Test czech`, + }); + createKey(p.id, `test.array[0]`, { + en: `Test english`, + cs: `Test czech`, + }); + visitExport(p.id); + projectId = p.id; + cy.gcy('export-submit-button').should('be.visible'); + }); + }); + + it('exports all to zip by default', () => { + cy.gcy('export-submit-button').click(); + cy.verifyDownload(getFileName('zip')); + }); + + it('exports one language to json', () => { + exportToggleLanguage('Česky'); + + cy.gcy('export-submit-button').click(); + + cy.readFile(downloadsFolder + '/' + getFileName('json', 'en')).should( + 'deep.equal', + { + 'test.array[0]': 'Test english', + 'test.test': 'Test english', + } + ); + }); + + it('exports with nested structure', () => { + exportToggleLanguage('English'); + exportSelectFormat('Structured JSON'); + + cy.gcy('export-submit-button').click(); + const fileName = getFileName('json', 'cs'); + cy.verifyDownload(fileName); + + const getFile = () => cy.readFile(downloadsFolder + '/' + fileName); + getFile().its('test').its('test').should('eq', 'Test czech'); + getFile().its('test').its('array[0]').should('eq', 'Test czech'); + }); + + it('the support arrays switch works', () => { + exportToggleLanguage('English'); + exportSelectFormat('Structured JSON'); + + cy.gcy('export-support_arrays-selector').click(); + cy.gcy('export-submit-button').click(); + + const fileName = getFileName('json', 'cs'); + cy.verifyDownload(fileName); + + cy.readFile(downloadsFolder + '/' + fileName) + .its('test') + .its('array') + .its(0) + .should('eq', 'Test czech'); + }); + + it('exports one language to xliff', () => { + exportToggleLanguage('Česky'); + + exportSelectFormat('XLIFF'); + + cy.gcy('export-submit-button').click(); + cy.verifyDownload(getFileName('xliff', 'en')); + }); + + afterEach(() => { + deleteProject(projectId); + }); +}); + +const getFileName = (extension: string, language?: string) => { + const dateStr = '_' + new Date().toISOString().split('T')[0]; + const languageStr = language ? `_${language}` : ''; + return `Test project${languageStr}${dateStr}.${extension}`; +}; diff --git a/e2e/cypress/e2e/projects/export/exportFormats.cy.ts b/e2e/cypress/e2e/projects/export/exportFormats.cy.ts new file mode 100644 index 0000000000..870ee14ff8 --- /dev/null +++ b/e2e/cypress/e2e/projects/export/exportFormats.cy.ts @@ -0,0 +1,52 @@ +import 'cypress-file-upload'; +import { + createKey, + deleteProject, + login, +} from '../../../common/apiCalls/common'; +import { + createExportableProject, + testExportFormats, + visitExport, +} from '../../../common/export'; + +describe('Export Formats', () => { + let projectId: number; + before(() => { + createExportableProject().then((p) => { + createKey(p.id, `test.test`, { + en: `Test english`, + cs: `Test czech`, + }); + createKey(p.id, `test.array[0]`, { + en: `Test english`, + cs: `Test czech`, + }); + visitExport(p.id); + projectId = p.id; + cy.gcy('export-submit-button').should('be.visible'); + }); + }); + + beforeEach(() => { + login(); + visitExport(projectId); + }); + + it('correctly exports to all formats', () => { + const submitFn = () => { + cy.gcy('export-submit-button').click(); + }; + + testExportFormats( + () => cy.intercept('POST', '/v2/projects/*/export'), + submitFn, + true, + () => {} + ); + }); + + after(() => { + deleteProject(projectId); + }); +}); diff --git a/e2e/cypress/e2e/projects/placeholdersDisabled.cy.ts b/e2e/cypress/e2e/projects/placeholdersDisabled.cy.ts new file mode 100644 index 0000000000..ab27936a8f --- /dev/null +++ b/e2e/cypress/e2e/projects/placeholdersDisabled.cy.ts @@ -0,0 +1,32 @@ +import { login } from '../../common/apiCalls/common'; +import { projectTestData } from '../../common/apiCalls/testData/testData'; +import { HOST } from '../../common/constants'; +import { waitForGlobalLoading } from '../../common/loading'; +import { createProject } from '../../common/projects'; +import { selectInProjectMenu } from '../../common/shared'; + +describe('disabled placeholders project', () => { + beforeEach(() => { + projectTestData.clean(); + projectTestData.generate(); + login('cukrberg@facebook.com', 'admin'); + }); + + afterEach(() => { + projectTestData.clean(); + }); + + it('creates project with disabled placeholders', () => { + const name = 'Disabled placeholders project'; + cy.visit(`${HOST}`); + createProject(name, 'Facebook'); + selectInProjectMenu('Project settings'); + cy.gcy('project-settings-menu-advanced').click(); + cy.gcy('project-settings-use-tolgee-placeholders-checkbox').click(); + waitForGlobalLoading(); + cy.reload(); + cy.gcy('project-settings-use-tolgee-placeholders-checkbox') + .find('input') + .should('not.be.checked'); + }); +}); diff --git a/e2e/cypress/e2e/projects/projectDashboard.cy.ts b/e2e/cypress/e2e/projects/projectDashboard.cy.ts index 4edb719100..b088497112 100644 --- a/e2e/cypress/e2e/projects/projectDashboard.cy.ts +++ b/e2e/cypress/e2e/projects/projectDashboard.cy.ts @@ -30,7 +30,7 @@ describe('Project stats', () => { }); createTag('new tag'); setStateToReviewed('english translation'); - createComment('new comment', 0, 'en'); + createComment('new comment', 'new translation', 'en'); resolveComment('new comment'); selectInProjectMenu('Project Dashboard'); diff --git a/e2e/cypress/e2e/projects/projectMembers.cy.ts b/e2e/cypress/e2e/projects/projectMembers.cy.ts index 3879cdcbd4..1682767d70 100644 --- a/e2e/cypress/e2e/projects/projectMembers.cy.ts +++ b/e2e/cypress/e2e/projects/projectMembers.cy.ts @@ -32,6 +32,7 @@ describe('Project members', () => { after(() => { setBypassSeatCountCheck(false); + projectTestData.clean(); }); describe('Permission settings', () => { @@ -41,6 +42,10 @@ describe('Project members', () => { projectTestData.generate(); }); + after(() => { + projectTestData.clean(); + }); + beforeEach(() => { login('cukrberg@facebook.com', 'admin'); }); diff --git a/e2e/cypress/e2e/projects/transferring.cy.ts b/e2e/cypress/e2e/projects/transferring.cy.ts index 564221c270..43764c4285 100644 --- a/e2e/cypress/e2e/projects/transferring.cy.ts +++ b/e2e/cypress/e2e/projects/transferring.cy.ts @@ -5,7 +5,7 @@ import { projectTransferringTestData } from '../../common/apiCalls/testData/test import { login } from '../../common/apiCalls/common'; import { waitForGlobalLoading } from '../../common/loading'; -describe('Projects Basics', () => { +describe('Projects Transferring', () => { beforeEach(() => { projectTransferringTestData.clean(); projectTransferringTestData.generate(); @@ -45,6 +45,7 @@ describe('Projects Basics', () => { const openTransferDialog = (projectName: string, organization: string) => { enterProjectSettings(projectName, organization); + gcy('project-settings-menu-advanced').click(); gcy('project-settings-transfer-button') .should('contain', 'Transfer') .wait(100) diff --git a/e2e/cypress/e2e/projects/webhooks.cy.ts b/e2e/cypress/e2e/projects/webhooks.cy.ts index 3e0af34c8d..6d9c45d0cb 100644 --- a/e2e/cypress/e2e/projects/webhooks.cy.ts +++ b/e2e/cypress/e2e/projects/webhooks.cy.ts @@ -4,7 +4,7 @@ import { gcyAdvanced, visitProjectDeveloperHooks, } from '../../common/shared'; -import { contentDelivery } from '../../common/apiCalls/testData/testData'; +import { contentDeliveryTestData } from '../../common/apiCalls/testData/testData'; import { login, setWebhookControllerStatus, @@ -19,8 +19,8 @@ describe('Content delivery', () => { beforeEach(() => { setWebhookControllerStatus(200); setFeature('WEBHOOKS', true); - contentDelivery.clean(); - contentDelivery.generateStandard().then((response) => { + contentDeliveryTestData.clean(); + contentDeliveryTestData.generateStandard().then((response) => { login(); const projectId = response.body.projects[0].id; visitProjectDeveloperHooks(projectId); diff --git a/e2e/cypress/e2e/security/sensitiveOperations.cy.ts b/e2e/cypress/e2e/security/sensitiveOperations.cy.ts index 44abacb761..679f0313cf 100644 --- a/e2e/cypress/e2e/security/sensitiveOperations.cy.ts +++ b/e2e/cypress/e2e/security/sensitiveOperations.cy.ts @@ -147,7 +147,7 @@ function doSensitiveOperation( otp: string = undefined ) { login(username, undefined, otp); - cy.visit(`${HOST}/projects/${projectId}/manage/edit`); + cy.visit(`${HOST}/projects/${projectId}/manage/edit/advanced`); gcy('project-settings-delete-button') .click() .then(() => { diff --git a/e2e/cypress/e2e/translations/base.cy.ts b/e2e/cypress/e2e/translations/base.cy.ts index 3d30d3e254..a22501d2ee 100644 --- a/e2e/cypress/e2e/translations/base.cy.ts +++ b/e2e/cypress/e2e/translations/base.cy.ts @@ -2,6 +2,7 @@ import { ProjectDTO } from '../../../../webapp/src/service/response.types'; import { deleteLanguage, visitLanguageSettings } from '../../common/languages'; import { createTranslation, + getTranslationCell, toggleLang, translationsBeforeEach, visitTranslations, @@ -75,6 +76,39 @@ describe('Translations Base', () => { } ); + it('will create translation with plural', () => { + cy.gcy('global-empty-list').should('be.visible'); + createTranslation({ + key: 'test-key', + translation: { one: '# key', other: '# keys' }, + }); + getTranslationCell('test-key', 'en') + .findDcy('translation-plural-parameter') + .contains('value') + .should('be.visible'); + getTranslationCell('test-key', 'en') + .findDcy('translation-plural-variant') + .contains('#1 key') + .should('be.visible'); + }); + + it('will create translation with plural and custom variable name', () => { + cy.gcy('global-empty-list').should('be.visible'); + createTranslation({ + key: 'test-key', + translation: { one: '# key', other: '# keys' }, + variableName: 'testVariable', + }); + getTranslationCell('test-key', 'en') + .findDcy('translation-plural-parameter') + .contains('testVariable') + .should('be.visible'); + getTranslationCell('test-key', 'en') + .findDcy('translation-plural-variant') + .contains('#1 key') + .should('be.visible'); + }); + it('will create translation with namespace', () => { cy.wait(100); cy.gcy('global-empty-list').should('be.visible'); diff --git a/e2e/cypress/e2e/translations/batchJobs.cy.ts b/e2e/cypress/e2e/translations/batchJobs.cy.ts index 8380d11682..c8f90088c3 100644 --- a/e2e/cypress/e2e/translations/batchJobs.cy.ts +++ b/e2e/cypress/e2e/translations/batchJobs.cy.ts @@ -74,13 +74,9 @@ describe('Batch jobs', { scrollBehavior: false }, () => { selectAll(); selectOperation('Clear translations'); selectLanguage('English'); - cy.gcy('translations-table-cell-translation-text') - .contains('en') - .should('exist'); + cy.gcy('translation-text').contains('en').should('exist'); executeBatchOperation(); - cy.gcy('translations-table-cell-translation-text') - .contains('en') - .should('not.exist'); + cy.gcy('translation-text').contains('en').should('not.exist'); }); it('will change state to reviewed and back to translated', () => { @@ -125,9 +121,9 @@ describe('Batch jobs', { scrollBehavior: false }, () => { cy.gcy('translations-row') .eq(1) .findDcy('translations-table-cell-language') - .contains('de') + .contains('German') .closestDcy('translations-table-cell') - .findDcy('translations-table-cell-translation-text') + .findDcy('translation-text') .contains('en') .should('exist'); }); diff --git a/e2e/cypress/e2e/translations/comments.cy.ts b/e2e/cypress/e2e/translations/comments.cy.ts index c09e5e91bd..8c48943b84 100644 --- a/e2e/cypress/e2e/translations/comments.cy.ts +++ b/e2e/cypress/e2e/translations/comments.cy.ts @@ -19,94 +19,95 @@ describe('Translation comments', () => { it("won't fail when translation is empty", () => { logInAs('franta'); - createComment('Cool comment 1', 1, 'en'); + createComment('Cool comment 1', 'B key', 'en'); }); it('franta can add comment (manage)', () => { logInAs('franta'); - createComment('Cool comment 1', 0, 'en'); + createComment('Cool comment 1', 'A key', 'en'); }); it('franta can delete all comments (manage)', () => { logInAs('franta'); - userCanDeleteComment(2, 'en', 'First comment'); - userCanDeleteComment(2, 'en', 'Second comment'); + userCanDeleteComment('C key', 'en', 'First comment'); + userCanDeleteComment('C key', 'en', 'Second comment'); }); it('pepa can load more comments (edit)', () => { logInAs('pepa'); - commentsButton(3, 'en').click(); + commentsButton('D key', 'en').click(); cy.gcy('comment-text').contains('comment 1').should('not.exist'); cy.gcy('translations-comments-load-more-button').scrollIntoView().click(); waitForGlobalLoading(); - cy.gcy('comment-text').contains('comment 1').should('exist'); + cy.gcy('translations-comments-load-more-button').scrollIntoView().click(); + waitForGlobalLoading(); + cy.gcy('comment-text').contains('comment 11').should('exist'); }); it('jindra can add comment (translate)', () => { logInAs('jindra'); - createComment('Cool comment 1', 0, 'en'); + createComment('Cool comment 1', 'A key', 'en'); }); it('jindra can get to edit mode (translate)', () => { logInAs('jindra'); - commentsButton(0, 'en').click(); - cy.gcy('translations-cell-tab-edit').should('be.visible').click(); + commentsButton('A key', 'en').click(); cy.gcy('global-editor').should('be.visible'); }); it('jindra can delete only his comments (translate)', () => { logInAs('jindra'); - userCanDeleteComment(2, 'en', 'First comment'); - userCantOpenMenu(2, 'en', 'Second comment'); + userCanDeleteComment('C key', 'en', 'First comment'); + userCantOpenMenu('C key', 'en', 'Second comment'); }); it('jindra can resolve comment (translate)', () => { logInAs('jindra'); - userCanResolveComment(2, 'en', 'First comment'); - userCanResolveComment(2, 'en', 'Second comment'); + userCanResolveComment('C key', 'en', 'First comment'); + userCanResolveComment('C key', 'en', 'Second comment'); }); it('jindra can unresolve comment (translate)', () => { logInAs('jindra'); - userCanResolveComment(2, 'en', 'First comment'); - userCanUnresolveComment(2, 'en', 'First comment'); + userCanResolveComment('C key', 'en', 'First comment'); + userCanUnresolveComment('C key', 'en', 'First comment'); }); it('jindra can resolve comment (translate cs)', () => { logInAs('jindra'); - userCanResolveComment(0, 'cs', 'First comment'); - userCanUnresolveComment(0, 'cs', 'First comment'); + userCanResolveComment('A key', 'cs', 'First comment'); + userCanUnresolveComment('A key', 'cs', 'First comment'); }); it('jindra is not able to get to edit mode (translate cs)', () => { logInAs('jindra'); - commentsButton(0, 'cs').click(); - cy.gcy('translations-cell-tab-edit').should('not.exist'); + commentsButton('A key', 'cs').click(); + cy.gcy('global-editor').should('not.exist'); }); it('vojta is not able to add comment (view)', () => { logInAs('vojta'); - commentsButton(0, 'en').click(); + commentsButton('A key', 'en').click(); cy.gcy('translations-comments-input').should('not.exist'); }); it('vojta is not able to get to edit mode (view)', () => { logInAs('vojta'); - commentsButton(0, 'en').click(); - cy.gcy('translations-cell-tab-edit').should('not.exist'); + commentsButton('A key', 'en').click(); + cy.gcy('global-editor').should('not.exist'); }); it('vojta cant delete any comments (view)', () => { logInAs('vojta'); - userCantOpenMenu(2, 'en', 'First comment'); - userCantOpenMenu(2, 'en', 'Second comment'); + userCantOpenMenu('C key', 'en', 'First comment'); + userCantOpenMenu('C key', 'en', 'Second comment'); }); it('vojta cant resolve any comments (view)', () => { logInAs('vojta'); - userCantResolveComment(2, 'en', 'First comment'); - userCantResolveComment(2, 'en', 'Second comment'); + userCantResolveComment('C key', 'en', 'First comment'); + userCantResolveComment('C key', 'en', 'Second comment'); }); }); @@ -117,41 +118,41 @@ function logInAs(user: string) { cy.waitForDom(); } -function userCanResolveComment(index: number, lang: string, comment: string) { - commentsButton(index, lang).click(); +function userCanResolveComment(key: string, lang: string, comment: string) { + commentsButton(key, lang).click(); resolveComment(comment); - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } -function userCantResolveComment(index: number, lang: string, comment: string) { - commentsButton(index, lang).click(); +function userCantResolveComment(key: string, lang: string, comment: string) { + commentsButton(key, lang).click(); cy.gcy('comment-text') .contains(comment) .closestDcy('comment') .findDcy('comment-resolve') .should('not.exist'); - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } -function userCanDeleteComment(index: number, lang: string, comment: string) { - commentsButton(index, lang).click(); +function userCanDeleteComment(key: string, lang: string, comment: string) { + commentsButton(key, lang).click(); deleteComment(comment); cy.contains(comment).should('not.exist'); - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } -function userCanUnresolveComment(index: number, lang: string, comment: string) { - commentsButton(index, lang).click(); +function userCanUnresolveComment(key: string, lang: string, comment: string) { + commentsButton(key, lang).click(); unresolveComment(comment); - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } -function userCantOpenMenu(index: number, lang: string, comment: string) { - commentsButton(index, lang).click(); +function userCantOpenMenu(key: string, lang: string, comment: string) { + commentsButton(key, lang).click(); cy.gcy('comment-text') .contains(comment) .closestDcy('comment') .findDcy('comment-menu') .should('not.exist'); - cy.gcy('translations-cell-close').click(); + cy.gcy('translations-cell-cancel-button').click(); } diff --git a/e2e/cypress/e2e/translations/outdated.cy.ts b/e2e/cypress/e2e/translations/outdated.cy.ts index dd855ddf71..a481561e53 100644 --- a/e2e/cypress/e2e/translations/outdated.cy.ts +++ b/e2e/cypress/e2e/translations/outdated.cy.ts @@ -51,7 +51,7 @@ describe('Translation states', () => { }); it("won't mark empty translation", () => { - editCell('Studený přeložený text 1', '', true); + editCell('Studený přeložený text 1', '{del}', true); waitForGlobalLoading(); editCell('Cool translated text 1', 'Cool translated text 1 edited', true); @@ -93,17 +93,13 @@ describe('Translation states', () => { cy.gcy('translations-add-button').click(); cy.gcy('translation-create-key-input').type('test_key'); cy.gcy('translation-create-translation-input') - .first() + .find('[contenteditable]') .type('Test translation'); - cy.gcy('translation-create-translation-input') - .eq(1) - .type('Testovací překlad'); - cy.gcy('global-form-save-button').click(); assertMessage('Key created'); - getOutdatedIndicator('Testovací překlad').should('not.exist'); + cy.gcy('translations-outdated-indicator').should('not.exist'); }); const getOutdatedIndicator = (translationText: string) => { diff --git a/e2e/cypress/e2e/translations/placeholdersDisabled.cy.ts b/e2e/cypress/e2e/translations/placeholdersDisabled.cy.ts new file mode 100644 index 0000000000..3037c8efd6 --- /dev/null +++ b/e2e/cypress/e2e/translations/placeholdersDisabled.cy.ts @@ -0,0 +1,69 @@ +import { ProjectDTO } from '../../../../webapp/src/service/response.types'; +import { + deleteProject, + login, + createProject, + createKey, +} from '../../common/apiCalls/common'; +import { waitForGlobalLoading } from '../../common/loading'; +import { + getPluralEditor, + getTranslationCell, + selectLangsInLocalstorage, + visitTranslations, +} from '../../common/translations'; + +describe('disabled placeholders translation plurals', () => { + let project: ProjectDTO = null; + beforeEach(() => { + return login().then(() => { + return createProject({ + name: 'Test', + languages: [ + { + tag: 'en', + name: 'English', + originalName: 'English', + }, + ], + icuPlaceholders: false, + }).then((r) => { + project = r.body as ProjectDTO; + selectLangsInLocalstorage(project.id, ['en']); + }); + }); + }); + + afterEach(() => { + deleteProject(project.id); + }); + + it('correctly escapes empty parameter', () => { + testTextStaysTheSame('{}'); + }); + + it('correctly escapes escape characeters', () => { + testTextStaysTheSame("'{}'"); + }); + + it('correctly escapes complicated translation', () => { + testTextStaysTheSame("'{}'''''''''''{}'{}'{}{}'"); + }); + + function testTextStaysTheSame(text: string) { + visit(); + createKey(project.id, 'key 01', {}, { isPlural: true }); + getTranslationCell('key 01', 'en').click(); + getPluralEditor('other').type(text, { + parseSpecialCharSequences: false, + }); + cy.gcy('translations-cell-save-button').click(); + waitForGlobalLoading(); + cy.gcy('global-editor').should('not.exist'); + cy.gcy('translation-plural-variant').contains(text); + } + + const visit = () => { + visitTranslations(project.id); + }; +}); diff --git a/e2e/cypress/e2e/translations/pluralTranslationTools.cy.ts b/e2e/cypress/e2e/translations/pluralTranslationTools.cy.ts new file mode 100644 index 0000000000..e70834df0e --- /dev/null +++ b/e2e/cypress/e2e/translations/pluralTranslationTools.cy.ts @@ -0,0 +1,130 @@ +import { ProjectDTO } from '../../../../webapp/src/service/response.types'; +import { createKey, deleteProject } from '../../common/apiCalls/common'; +import { waitForGlobalLoading } from '../../common/loading'; +import { + getPluralEditor, + getTranslationCell, + selectLangsInLocalstorage, + translationsBeforeEach, + visitTranslations, +} from '../../common/translations'; + +describe('translation tools panel with plurals', () => { + let project: ProjectDTO = null; + + beforeEach(() => { + translationsBeforeEach() + .then((p) => (project = p)) + .then(() => { + selectLangsInLocalstorage(project.id, ['en', 'cs']); + }); + }); + + afterEach(() => { + deleteProject(project.id); + }); + + it('will suggest correctly from MT', () => { + createMTKeys(); + visit(); + + // check variant "one" + getTranslationCell('mt key 1', 'cs').click(); + getPluralEditor('one').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-machine-translation-item') + .contains('#1 item translated with GOOGLE from en to cs') + .should('be.visible') + .click(); + + // check variant "few" + getPluralEditor('few').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-machine-translation-item') + .contains('#2 items translated with GOOGLE from en to cs') + .should('be.visible') + .click(); + + // check variant "other" + getPluralEditor('other').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-machine-translation-item') + .contains('#10 items translated with GOOGLE from en to cs') + .should('be.visible') + .click(); + + cy.gcy('translations-cell-save-button').click(); + waitForGlobalLoading(); + cy.gcy('global-editor').should('not.exist'); + }); + + it('will suggest correctly from TM', () => { + createTMKeys(); + visit(); + + // check variant "one" + getTranslationCell('tm key 1', 'cs').click(); + getPluralEditor('one').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-translation-memory-item') + .contains('#1 položka') + .should('be.visible') + .click(); + + // check variant "few" + getPluralEditor('few').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-translation-memory-item') + .contains('#2 položky') + .should('be.visible') + .click(); + + // check variant "other" + getPluralEditor('other').click(); + waitForGlobalLoading(); + cy.gcy('translation-tools-translation-memory-item') + .contains('#10 položek') + .should('be.visible') + .click(); + + cy.gcy('translations-cell-save-button').click(); + waitForGlobalLoading(); + cy.gcy('global-editor').should('not.exist'); + }); + + function createTMKeys() { + createKey( + project.id, + 'tm key 1', + { + en: '{value, plural, one {# item} other {# items}}', + }, + { isPlural: true } + ); + + return createKey( + project.id, + 'tm key 2', + { + en: '{value, plural, one {# item} other {# items}}', + cs: '{value, plural, one {# položka} few {# položky} other {# položek}}', + }, + { isPlural: true } + ); + } + + function createMTKeys() { + return createKey( + project.id, + 'mt key 1', + { + en: '{value, plural, one {# item} other {# items}}', + }, + { isPlural: true } + ); + } + + const visit = () => { + visitTranslations(project.id); + }; +}); diff --git a/e2e/cypress/e2e/translations/plurals.cy.ts b/e2e/cypress/e2e/translations/plurals.cy.ts new file mode 100644 index 0000000000..4acc734d31 --- /dev/null +++ b/e2e/cypress/e2e/translations/plurals.cy.ts @@ -0,0 +1,59 @@ +import { ProjectDTO } from '../../../../webapp/src/service/response.types'; +import { + createTranslation, + getCell, + getTranslationCell, + translationsBeforeEach, + visitTranslations, +} from '../../common/translations'; +import { waitForGlobalLoading } from '../../common/loading'; +import { createKey, deleteProject } from '../../common/apiCalls/common'; + +describe('Translations Base', () => { + let project: ProjectDTO = null; + + beforeEach(() => { + translationsBeforeEach(['en', 'cs']).then((p) => (project = p)); + }); + + afterEach(() => { + deleteProject(project.id); + }); + + it('will switch translation to plural, without a problem', () => { + cy.wait(100); + cy.gcy('global-empty-list').should('be.visible'); + createTranslation({ + key: 'Test key', + translation: 'Translated key with { stuff to escape', + }); + + getCell('Test key').click(); + cy.gcy('key-plural-checkbox').click(); + cy.gcy('translations-cell-save-button').click(); + waitForGlobalLoading(); + getTranslationCell('Test key', 'en') + .contains("Translated key with '{' stuff to escape") + .should('be.visible'); + }); + + it('will change plural parameter name for all translations', () => { + createKey( + project.id, + 'Test key', + { + en: '{value, plural, one {# item} other {# items}}', + cs: '{value, plural, one {# položka} few {# položky} other {# položek}}', + }, + { isPlural: true } + ); + visitTranslations(project.id); + waitForGlobalLoading(); + + getCell('Test key').click(); + cy.gcy('key-plural-checkbox-expand').click(); + cy.gcy('key-plural-variable-name').clear().type('testVariable'); + cy.gcy('translations-cell-save-button').click(); + waitForGlobalLoading(); + }); +}); diff --git a/e2e/cypress/e2e/translations/singleKeyForm.cy.ts b/e2e/cypress/e2e/translations/singleKeyForm.cy.ts index a5aef17ac5..cea6c32f57 100644 --- a/e2e/cypress/e2e/translations/singleKeyForm.cy.ts +++ b/e2e/cypress/e2e/translations/singleKeyForm.cy.ts @@ -64,20 +64,20 @@ describe('Single key form', () => { // changing langs in translations list should influnece // default language in localstorage visitTranslations(projectId); - languageIsSelected('cs'); - languageIsSelected('en'); + languageIsSelected('Czech'); + languageIsSelected('English'); toggleLang('Czech'); // translation single view should take settings from localstorage // but't not modify them visitSingleKey(projectId, 'A key'); - languageIsSelected('en'); - languageIsNotSelected('cs'); + languageIsSelected('English'); + languageIsNotSelected('Czech'); toggleLang('Czech'); visitTranslations(projectId); - languageIsSelected('en'); - languageIsNotSelected('cs'); + languageIsSelected('English'); + languageIsNotSelected('Czech'); }); function languageIsSelected(lang: string) { diff --git a/e2e/cypress/e2e/translations/translationMemory.cy.ts b/e2e/cypress/e2e/translations/translationMemory.cy.ts index f1927553de..006d274831 100644 --- a/e2e/cypress/e2e/translations/translationMemory.cy.ts +++ b/e2e/cypress/e2e/translations/translationMemory.cy.ts @@ -28,10 +28,6 @@ describe('Translation memory', () => { it('will show correct suggestions', () => { waitForGlobalLoading(); openEditor('Studený přeložený text 1'); - cy.gcy('translation-tools-translation-memory-item').should( - 'have.length', - 3 - ); cy.gcy('translation-tools-translation-memory-item') .contains('Studený přeložený text 2') .should('be.visible'); diff --git a/e2e/cypress/e2e/translations/with5translations/shortcuts.cy.ts b/e2e/cypress/e2e/translations/with5translations/shortcuts.cy.ts index 0dd8264709..9f276e8478 100644 --- a/e2e/cypress/e2e/translations/with5translations/shortcuts.cy.ts +++ b/e2e/cypress/e2e/translations/with5translations/shortcuts.cy.ts @@ -1,7 +1,6 @@ import { ProjectDTO } from '../../../../../webapp/src/service/response.types'; import { - assertAvailableCommands, editCell, IS_MAC, move, @@ -96,19 +95,4 @@ describe('Shortcuts', () => { cy.focused().contains('Cool translated text 2').should('be.visible'); } ); - - it('will show correct context hint', () => { - assertAvailableCommands(['Move']); - - selectFirst(); - cy.gcy('translations-shortcuts-command').should('have.length', 3); - assertAvailableCommands(['Move', 'Edit', 'Reviewed']); - - editCell('Cool translated text 1'); - assertAvailableCommands(['Save', 'Save & continue', 'Reviewed']); - cy.focused().type('{esc}'); - - move('leftarrow'); - assertAvailableCommands(['Move', 'Edit']); - }); }); diff --git a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts index d1da222655..8765cb4dbb 100644 --- a/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts +++ b/e2e/cypress/e2e/translations/with5translations/withViews.cy.ts @@ -47,7 +47,7 @@ describe('Views with 5 Translations', () => { editCell('Studený přeložený text 1'); getCellInsertBaseButton().click(); - cy.get('.CodeMirror') + cy.gcy('global-editor') .first() .contains('Cool translated text 1') .should('be.visible'); diff --git a/e2e/cypress/fixtures/import/error.jsn b/e2e/cypress/fixtures/import/error.jsn deleted file mode 100644 index 89569f46f8..0000000000 --- a/e2e/cypress/fixtures/import/error.jsn +++ /dev/null @@ -1 +0,0 @@ -asdlasj l \ No newline at end of file diff --git a/e2e/cypress/fixtures/import/error.json.zip b/e2e/cypress/fixtures/import/error.json.zip new file mode 100644 index 0000000000000000000000000000000000000000..03656ca2dd913ecc8592c09352d8f02230d10c8e GIT binary patch literal 493 zcmWIWW@Zs#-~htRpu%7VDBuLrTnq{fsYONkMS5Aq`FWusybSDOC#NJX?|72B1c*y3 zxEUB(zA`c}0QEC4cQbb^r~z?+@Jb(!QbZlEy8pa85^EC$Mm0sKKS~Cec%u42xwrMAi=CIAkA2)DA4?)Te5?xPf^h^ zK+WSgkHRyz;6oh(;=bq3U-j0}IN^C!TjP|z_gU{V-hQbGDM=QqR%A?u&_z`&A55DSOvv4j=6xyW{cf&>PZG$sR?WCc)wH!B;+ O7$zXx4WwllK|BEBf_cCI literal 0 HcmV?d00001 diff --git a/e2e/cypress/fixtures/import/po/placeholders.po b/e2e/cypress/fixtures/import/po/placeholders.po new file mode 100644 index 0000000000..803cb82fda --- /dev/null +++ b/e2e/cypress/fixtures/import/po/placeholders.po @@ -0,0 +1,12 @@ +msgid "" +msgstr "" +"Project-Id-Version: Tolgee 1.0.1\n" +"Report-Msgid-Bugs-To: info@tolgee.io \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "Welcome back, %1$s! Your last visit was on %2$s" +msgstr "Willkommen zurück, %1$s! Dein letzter Besuch war am %2$s" diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index d0563ff352..1cca49dd8a 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -160,6 +160,7 @@ declare namespace DataCy { "export-state-selector" | "export-state-selector-item" | "export-submit-button" | + "export-support_arrays-selector" | "former-user-name" | "generate-api-key-dialog-description-input" | "generate-pat-dialog-content" | @@ -189,12 +190,14 @@ declare namespace DataCy { "import-conflicts-not-resolved-dialog" | "import-conflicts-not-resolved-dialog-cancel-button" | "import-conflicts-not-resolved-dialog-resolve-button" | + "import-convert-placeholders-to-icu-checkbox" | "import-file-error" | "import-file-error-collapse-button" | "import-file-error-more-less-button" | "import-file-input" | "import-file-issues-button" | "import-file-issues-dialog" | + "import-override-key-descriptions-checkbox" | "import-progress" | "import-progress-overlay" | "import-resolution-dialog-accept-imported-button" | @@ -245,7 +248,11 @@ declare namespace DataCy { "invite-generate-button" | "key-edit-tab-advanced" | "key-edit-tab-context" | + "key-edit-tab-custom-properties" | "key-edit-tab-general" | + "key-plural-checkbox" | + "key-plural-checkbox-expand" | + "key-plural-variable-name" | "language-ai-prompt-dialog-description-input" | "language-ai-prompt-dialog-save" | "language-delete-button" | @@ -400,8 +407,11 @@ declare namespace DataCy { "project-settings-languages-add" | "project-settings-languages-list-edit-button" | "project-settings-languages-list-name" | + "project-settings-menu-advanced" | + "project-settings-menu-general" | "project-settings-name" | "project-settings-transfer-button" | + "project-settings-use-tolgee-placeholders-checkbox" | "project-states-bar-bar" | "project-states-bar-dot" | "project-states-bar-legend" | @@ -454,6 +464,7 @@ declare namespace DataCy { "storage-subtitle" | "tag-autocomplete-input" | "tag-autocomplete-option" | + "this-is-the-element" | "top-banner" | "top-banner-content" | "top-banner-dismiss-button" | @@ -465,24 +476,28 @@ declare namespace DataCy { "translation-edit-delete-button" | "translation-edit-key-field" | "translation-edit-translation-field" | + "translation-editor" | "translation-field-label" | "translation-history-item" | + "translation-panel" | + "translation-panel-content" | + "translation-panel-toggle" | + "translation-plural-parameter" | + "translation-plural-variant" | "translation-state-button" | + "translation-text" | "translation-tools-machine-translation-item" | "translation-tools-translation-memory-item" | "translations-add-button" | "translations-auto-translated-clear-button" | "translations-auto-translated-indicator" | "translations-cell-cancel-button" | - "translations-cell-close" | "translations-cell-comments-button" | "translations-cell-edit-button" | "translations-cell-insert-base-button" | "translations-cell-save-button" | "translations-cell-screenshots-button" | - "translations-cell-tab-comments" | - "translations-cell-tab-edit" | - "translations-cell-tab-history" | + "translations-cell-switch-mode" | "translations-comments-input" | "translations-comments-load-more-button" | "translations-filter-clear-all" | @@ -507,7 +522,6 @@ declare namespace DataCy { "translations-table-cell" | "translations-table-cell-language" | "translations-table-cell-translation" | - "translations-table-cell-translation-text" | "translations-tag" | "translations-tag-add" | "translations-tag-close" | diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt index 6d21bdbc7a..1157e83535 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/machineTranslation/CloudTolgeeTranslateApiServiceImpl.kt @@ -57,7 +57,10 @@ class CloudTolgeeTranslateApiServiceImpl( params.formality, params.metadata?.projectDescription, params.metadata?.languageDescription, + params.pluralForms, + params.pluralFormExamples, ) + val request = HttpEntity(requestBody, headers) checkPositiveRateLimitTokens(params) @@ -132,6 +135,8 @@ class CloudTolgeeTranslateApiServiceImpl( val formality: Formality? = null, val projectDescription: String? = null, val languageNote: String? = null, + val pluralForms: Map? = null, + val pluralFormExamples: Map? = null, ) class TolgeeTranslateExample( diff --git a/gradle/docker.gradle b/gradle/docker.gradle index f7d508d237..5d1b086221 100644 --- a/gradle/docker.gradle +++ b/gradle/docker.gradle @@ -28,6 +28,28 @@ task docker { dependsOn("dockerPrepare") } +void createDockerBuildxTask(String taskName, boolean isPush) { + task(taskName) { + doLast { + if (project.hasProperty('dockerImageTag')) { + def commandParams = ["docker", "buildx", "build", ".", "-t", project.property('dockerImageTag'), "--platform", "linux/arm64,linux/amd64"] + if (isPush) { + commandParams += ["--push"] + } + exec { + workingDir dockerPath + commandLine commandParams + } + } else { + throw new GradleException('A "dockerImageTag" project property must be defined! e.g. ./gradlew dockerBuildx -PdockerImageTag=myImageTag'); + } + } + dependsOn("dockerPrepare") + } +} + +createDockerBuildxTask("dockerBuildxPush", true) +createDockerBuildxTask("dockerBuildx", false) task cleanDocker{ delete(dockerPath) diff --git a/gradle/webapp.gradle b/gradle/webapp.gradle index bbfa8c70e4..7f039e7103 100644 --- a/gradle/webapp.gradle +++ b/gradle/webapp.gradle @@ -27,7 +27,7 @@ task buildWebapp(type: Exec) { commandLine npmCommandName, "run", "build" workingDir = webappPath inputs.dir("${project.projectDir}/webapp/") - outputs.dir("${project.projectDir}/webapp/dist/") + outputs.dir("${project.projectDir}/webapp/build/") dependsOn "installWebappDeps", "updateStaticTranslations" } @@ -40,7 +40,6 @@ task createBuildDir() { task updateStaticTranslations(type: Exec) { onlyIf { System.getenv("SKIP_WEBAPP_BUILD") != "true" } - def tolgeeApiKey = System.env.TOLGEE_API_KEY def tolgeeApiUrl = System.env.TOLGEE_API_URL onlyIf { tolgeeApiUrl != null && tolgeeApiUrl != "" } workingDir = webappPath diff --git a/pluralTest.json b/pluralTest.json new file mode 100644 index 0000000000..481aeb5bd6 --- /dev/null +++ b/pluralTest.json @@ -0,0 +1,5 @@ +{ + "test2": "{count, plural, one {# test} other {# tests}}", + "test": "No plural!", + "hey": "{count, plural, one {# test} other {# tests}}" +} diff --git a/settings.gradle b/settings.gradle index 5e231d9fa0..c4c90d30fe 100644 --- a/settings.gradle +++ b/settings.gradle @@ -55,7 +55,7 @@ dependencyResolutionManagement { library('springmockk', 'com.ninja-squad:springmockk:3.0.1') library('mockito', 'org.mockito.kotlin:mockito-kotlin:5.0.0') library('commonsCodec', 'commons-codec:commons-codec:1.15') - library('icu4j', 'com.ibm.icu:icu4j:69.1') + library('icu4j', 'com.ibm.icu:icu4j:74.2') library('jsonUnitAssert', 'net.javacrumbs.json-unit:json-unit-assertj:2.28.0') library('amazonS3', "software.amazon.awssdk:s3:$amazonAwsSdkVersion") library('amazonSTS', "software.amazon.awssdk:sts:$amazonAwsSdkVersion") diff --git a/webapp/jest.config.js b/webapp/jest.config.js new file mode 100644 index 0000000000..5ed1ed69ef --- /dev/null +++ b/webapp/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest', + testEnvironment: 'node', +}; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 41d8c5551f..b0e0a2df36 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -8,6 +8,7 @@ "name": "webapp", "version": "0.1.0", "dependencies": { + "@codemirror/lang-json": "6.0.0", "@dicebear/avatars": "4.10.2", "@dicebear/avatars-identicon-sprites": "4.10.2", "@dicebear/avatars-initials-sprites": "4.10.2", @@ -22,11 +23,12 @@ "@openreplay/tracker": "^3.5.4", "@sentry/browser": "^7.80.0", "@stomp/stompjs": "^6.1.2", + "@tginternal/editor": "^1.13.4", "@tolgee/format-icu": "^5.16.0", "@tolgee/react": "^5.16.0", "@vitejs/plugin-react": "^4.2.1", "clsx": "^1.1.1", - "codemirror": "^5.62.0", + "codemirror": "^6.0.1", "copy-to-clipboard": "^3.3.1", "date-fns": "2.29.2", "diff": "^5.0.0", @@ -39,7 +41,6 @@ "prism-react-renderer": "1.2.1", "prism-svelte": "0.4.7", "react": "^17.0.1", - "react-codemirror2": "^7.3.0", "react-cropper": "2.1.8", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", @@ -48,7 +49,7 @@ "react-google-recaptcha-v3": "1.9.5", "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", - "react-list": "^0.8.16", + "react-list": "^0.8.17", "react-markdown": "^8.0.4", "react-qr-code": "^2.0.7", "react-query": "^3.39.2", @@ -63,6 +64,7 @@ "sockjs-client": "^1.6.1", "use-context-selector": "^1.3.9", "use-debounce": "^10.0.0", + "usehooks-ts": "^2.12.1", "uuid": "9.0.0", "web-vitals": "^2.1.0", "yup": "^0.32.9", @@ -76,14 +78,13 @@ "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", "@tginternal/language-util": "^1.0.6", - "@tolgee/cli": "^1.0.1", - "@types/codemirror": "^5.60.2", + "@tolgee/cli": "^1.4.0", "@types/diff": "^5.0.2", "@types/jest": "^26.0.24", "@types/node": "^18.19.4", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.9", - "@types/react-list": "^0.8.6", + "@types/react-list": "^0.8.11", "@types/react-redux": "^7.1.18", "@types/react-router-dom": "^5.1.8", "@types/yup": "^0.29.13", @@ -115,9 +116,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", - "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", "dev": true }, "node_modules/@ampproject/remapping": { @@ -153,25 +154,26 @@ } }, "node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "version": "7.12.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", + "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", - "convert-source-map": "^2.0.0", + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-module-transforms": "^7.12.1", + "@babel/helpers": "^7.12.5", + "@babel/parser": "^7.12.7", + "@babel/template": "^7.12.7", + "@babel/traverse": "^7.12.9", + "@babel/types": "^7.12.7", + "convert-source-map": "^1.7.0", "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" }, "engines": { "node": ">=6.9.0" @@ -210,6 +212,27 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", @@ -325,13 +348,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.7.tgz", - "integrity": "sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", + "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9" }, "engines": { "node": ">=6.9.0" @@ -351,9 +374,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "bin": { "parser": "bin/babel-parser.js" }, @@ -444,9 +467,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", - "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -460,22 +483,22 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", + "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", - "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", + "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", "dependencies": { "@babel/code-frame": "^7.23.5", "@babel/generator": "^7.23.6", @@ -483,8 +506,8 @@ "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", + "@babel/parser": "^7.23.9", + "@babel/types": "^7.23.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -493,9 +516,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", + "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -505,6 +528,91 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz", + "integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.0.tgz", + "integrity": "sha512-DvTcYTKLmg2viADXlTdufrT334M9jowe1qO02W28nvm+nejcvhM5vot5mE8/kPrxYw/HJHhwu1z2PyBpnMLCNQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.0.tgz", + "integrity": "sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", + "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/view": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.24.1.tgz", + "integrity": "sha512-sBfP4rniPBRQzNakwuQEqjEuiJDWJyF2kqLLqij4WXRoVwPPJfjx966Eq3F7+OPQxDtMt/Q9MWLoZLWjeveBlg==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@date-io/core": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", @@ -624,11 +732,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" - }, "node_modules/@emotion/cache": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", @@ -745,9 +848,9 @@ "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", "cpu": [ "ppc64" ], @@ -761,9 +864,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", "cpu": [ "arm" ], @@ -777,9 +880,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", "cpu": [ "arm64" ], @@ -793,9 +896,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", "cpu": [ "x64" ], @@ -809,9 +912,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", "cpu": [ "arm64" ], @@ -825,9 +928,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", "cpu": [ "x64" ], @@ -841,9 +944,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", "cpu": [ "arm64" ], @@ -857,9 +960,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", "cpu": [ "x64" ], @@ -873,9 +976,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", "cpu": [ "arm" ], @@ -889,9 +992,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", "cpu": [ "arm64" ], @@ -905,9 +1008,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", "cpu": [ "ia32" ], @@ -921,9 +1024,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", "cpu": [ "loong64" ], @@ -937,9 +1040,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", "cpu": [ "mips64el" ], @@ -953,9 +1056,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", "cpu": [ "ppc64" ], @@ -969,9 +1072,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", "cpu": [ "riscv64" ], @@ -985,9 +1088,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", "cpu": [ "s390x" ], @@ -1001,9 +1104,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", "cpu": [ "x64" ], @@ -1017,9 +1120,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", "cpu": [ "x64" ], @@ -1033,9 +1136,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", "cpu": [ "x64" ], @@ -1049,9 +1152,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", "cpu": [ "x64" ], @@ -1065,9 +1168,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", "cpu": [ "arm64" ], @@ -1081,9 +1184,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", "cpu": [ "ia32" ], @@ -1097,9 +1200,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", "cpu": [ "x64" ], @@ -1156,6 +1259,25 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -1180,6 +1302,31 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -1202,28 +1349,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", + "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "dependencies": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.1" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", - "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", "dependencies": { - "@floating-ui/dom": "^1.5.1" + "@floating-ui/dom": "^1.6.1" }, "peerDependencies": { "react": ">=16.8.0", @@ -1231,16 +1378,16 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "node_modules/@formatjs/ecma402-abstract": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.0.tgz", - "integrity": "sha512-PEVLoa3zBevWSCZzPIM/lvPCi8P5l4G+NXQMc/CjEiaCWgyHieUoo0nM7Bs0n/NbuQ6JpXEolivQ9pKSBHaDlA==", + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz", + "integrity": "sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==", "dependencies": { - "@formatjs/intl-localematcher": "0.5.2", + "@formatjs/intl-localematcher": "0.5.4", "tslib": "^2.4.0" } }, @@ -1253,21 +1400,21 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.3.tgz", - "integrity": "sha512-X/jy10V9S/vW+qlplqhMUxR8wErQ0mmIYSq4mrjpjDl9mbuGcCILcI1SUYkL5nlM4PJqpc0KOS0bFkkJNPxYRw==", + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.6.tgz", + "integrity": "sha512-etVau26po9+eewJKYoiBKP6743I1br0/Ie00Pb/S/PtmYfmjTcOn2YCh2yNkSZI12h6Rg+BOgQYborXk46BvkA==", "dependencies": { - "@formatjs/ecma402-abstract": "1.18.0", - "@formatjs/icu-skeleton-parser": "1.7.0", + "@formatjs/ecma402-abstract": "1.18.2", + "@formatjs/icu-skeleton-parser": "1.8.0", "tslib": "^2.4.0" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.7.0.tgz", - "integrity": "sha512-Cfdo/fgbZzpN/jlN/ptQVe0lRHora+8ezrEeg2RfrNjyp+YStwBy7cqDY8k5/z2LzXg6O0AdzAV91XS0zIWv+A==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.0.tgz", + "integrity": "sha512-QWLAYvM0n8hv7Nq5BEs4LKIjevpVpbGLAJgOaYzg9wABEoX1j0JO1q2/jVkO6CVlq0dbsxZCngS5aXbysYueqA==", "dependencies": { - "@formatjs/ecma402-abstract": "1.18.0", + "@formatjs/ecma402-abstract": "1.18.2", "tslib": "^2.4.0" } }, @@ -1281,9 +1428,9 @@ } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.2.tgz", - "integrity": "sha512-txaaE2fiBMagLrR4jYhxzFO6wEdEG4TPMqrzBAcbr4HFUYzH/YC+lg6OIzKCHm8WgDdyQevxbAAV1OgcXctuGw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", + "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", "dependencies": { "tslib": "^2.4.0" } @@ -1302,12 +1449,78 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jest/types": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", @@ -1423,31 +1636,51 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", + "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", + "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", + "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@mdx-js/loader": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-1.6.22.tgz", @@ -1494,52 +1727,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/@mdx-js/mdx/node_modules/@babel/core": { - "version": "7.12.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", - "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.12.5", - "@babel/helper-module-transforms": "^7.12.1", - "@babel/helpers": "^7.12.5", - "@babel/parser": "^7.12.7", - "@babel/template": "^7.12.7", - "@babel/traverse": "^7.12.9", - "@babel/types": "^7.12.7", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.1", - "json5": "^2.1.2", - "lodash": "^4.17.19", - "resolve": "^1.3.2", - "semver": "^5.4.1", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@mdx-js/mdx/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "node_modules/@mdx-js/mdx/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/@mdx-js/react": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", @@ -1664,9 +1851,9 @@ } }, "node_modules/@mdx-js/rollup/node_modules/mdast-util-to-hast": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz", - "integrity": "sha512-U5I+500EOOw9e3ZrclN3Is3fRpw8c19SMyNZlZ2IS+7vLsNzb2Om11VpIVOR+/0137GhZsFEF6YiKD5+0Hr2Og==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz", + "integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -1675,7 +1862,8 @@ "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -1711,9 +1899,9 @@ } }, "node_modules/@mdx-js/rollup/node_modules/remark-rehype": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.0.0.tgz", - "integrity": "sha512-vx8x2MDMcxuE4lBmQ46zYUDfcFMmvg80WYX+UNLeG6ixjdCCLcw1lrgAukwBTuOFsS78eoAedHGn9sNM0w7TPw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.0.tgz", + "integrity": "sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -1823,16 +2011,16 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-beta.29", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.29.tgz", - "integrity": "sha512-OXfUssYrB6ch/xpBVHMKAjThPlI9VyGGKdvQLMXef2j39wXfcxPlUVQlwia/lmE3rxWIGvbwkZsDtNYzLMsDUg==", - "dependencies": { - "@babel/runtime": "^7.23.6", - "@floating-ui/react-dom": "^2.0.4", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.2", + "version": "5.0.0-beta.33", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.33.tgz", + "integrity": "sha512-WcSpoJUw/UYHXpvgtl4HyMar2Ar97illUpqiS/X1gtSBp6sdDW6kB2BJ9OlVQ+Kk/RL2GDp/WHA9sbjAYV35ow==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "@floating-ui/react-dom": "^2.0.6", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.6", "@popperjs/core": "^2.11.8", - "clsx": "^2.0.0", + "clsx": "^2.1.0", "prop-types": "^15.8.1" }, "engines": { @@ -1862,20 +2050,20 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.2.tgz", - "integrity": "sha512-0vk4ckS2w1F5PmkSXSd7F/QuRlNcPqWTJ8CPl+HQRLTIhJVS/VKEI+3dQufOdKfn2wS+ecnvlvXerbugs+xZ8Q==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.6.tgz", + "integrity": "sha512-0aoWS4qvk1uzm9JBs83oQmIMIQeTBUeqqu8u+3uo2tMznrB5fIKqQVCbCgq+4Tm4jG+5F7dIvnjvQ2aV7UKtdw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.2.tgz", - "integrity": "sha512-Vs0Z6cd6ieTavMjqPvIJJfwsKaCLdRSErk5LjKdZlBqk7r2SR6roDyhVTQuZOeCzjEFj0qZ4iVPp2DJZRwuYbw==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.6.tgz", + "integrity": "sha512-GnkxMtlhs+8ieHLmCytg00ew0vMOiXGFCw8Ra9nxMsBjBqnrOI5gmXqUm+sGggeEU/HG8HyeqC1MX/IxOBJHzA==", "dependencies": { - "@babel/runtime": "^7.23.6" + "@babel/runtime": "^7.23.8" }, "engines": { "node": ">=12.0.0" @@ -1896,16 +2084,16 @@ } }, "node_modules/@mui/lab": { - "version": "5.0.0-alpha.158", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.158.tgz", - "integrity": "sha512-MNn/J07GAipfElEEzDD9O7KLxz+mKgONJ+zBmlLgcCpDFsOh6nAuVQ2ONbeg1cgV/e553jXv8QHTWSRXw8KX4A==", - "dependencies": { - "@babel/runtime": "^7.23.6", - "@mui/base": "5.0.0-beta.29", - "@mui/system": "^5.15.2", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.2", - "clsx": "^2.0.0", + "version": "5.0.0-alpha.162", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.162.tgz", + "integrity": "sha512-nSdlhq1YVozKXn6mtItWmnU9b/gQ708RSWG6C+M/Y096MlQ7Mz1gdNWOEwcGw2HaNoNgDvuG0+0HKARAMIMaLg==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "@mui/base": "5.0.0-beta.33", + "@mui/system": "^5.15.6", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.6", + "clsx": "^2.1.0", "prop-types": "^15.8.1" }, "engines": { @@ -1918,7 +2106,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material": ">=5.10.11", + "@mui/material": ">=5.15.0", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" @@ -1944,18 +2132,18 @@ } }, "node_modules/@mui/material": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.2.tgz", - "integrity": "sha512-JnoIrpNmEHG5uC1IyEdgsnDiaiuCZnUIh7f9oeAr87AvBmNiEJPbo7XrD7kBTFWwp+b97rQ12QdSs9CLhT2n/A==", - "dependencies": { - "@babel/runtime": "^7.23.6", - "@mui/base": "5.0.0-beta.29", - "@mui/core-downloads-tracker": "^5.15.2", - "@mui/system": "^5.15.2", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.2", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.6.tgz", + "integrity": "sha512-rw7bDdpi2kzfmcDN78lHp8swArJ5sBCKsn+4G3IpGfu44ycyWAWX0VdlvkjcR9Yrws2KIm7c+8niXpWHUDbWoA==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "@mui/base": "5.0.0-beta.33", + "@mui/core-downloads-tracker": "^5.15.6", + "@mui/system": "^5.15.6", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.6", "@types/react-transition-group": "^4.4.10", - "clsx": "^2.0.0", + "clsx": "^2.1.0", "csstype": "^3.1.2", "prop-types": "^15.8.1", "react-is": "^18.2.0", @@ -1996,12 +2184,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.2.tgz", - "integrity": "sha512-KlXx5TH1Mw9omSY+Q6rz5TA/P71meSYaAOeopiW8s6o433+fnOxS17rZbmd1RnDZGCo+j24TfCavQuCMBAZnQA==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.6.tgz", + "integrity": "sha512-ZBX9E6VNUSscUOtU8uU462VvpvBS7eFl5VfxAzTRVQBHflzL+5KtnGrebgf6Nd6cdvxa1o0OomiaxSKoN2XDmg==", "dependencies": { - "@babel/runtime": "^7.23.6", - "@mui/utils": "^5.15.2", + "@babel/runtime": "^7.23.8", + "@mui/utils": "^5.15.6", "prop-types": "^15.8.1" }, "engines": { @@ -2022,11 +2210,11 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.2.tgz", - "integrity": "sha512-fYEN3IZzbebeHwAmQHhxwruiOIi8W74709qXg/7tgtHV4byQSmPgnnKsZkg0hFlzjEbcJIRZyZI0qEecgpR2cg==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.6.tgz", + "integrity": "sha512-KAn8P8xP/WigFKMlEYUpU9z2o7jJnv0BG28Qu1dhNQVutsLVIFdRf5Nb+0ijp2qgtcmygQ0FtfRuXv5LYetZTg==", "dependencies": { - "@babel/runtime": "^7.23.6", + "@babel/runtime": "^7.23.8", "@emotion/cache": "^11.11.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -2053,16 +2241,16 @@ } }, "node_modules/@mui/system": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.2.tgz", - "integrity": "sha512-I7CzLiHDtU/BTobJgSk+wPGGWG95K8lYfdFEnq//wOgSrLDAdOVvl2gleDxJWO+yAbGz4RKEOnR9KuD+xQZH4A==", - "dependencies": { - "@babel/runtime": "^7.23.6", - "@mui/private-theming": "^5.15.2", - "@mui/styled-engine": "^5.15.2", - "@mui/types": "^7.2.11", - "@mui/utils": "^5.15.2", - "clsx": "^2.0.0", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.6.tgz", + "integrity": "sha512-J01D//u8IfXvaEHMBQX5aO2l7Q+P15nt96c4NskX7yp5/+UuZP8XCQJhtBtLuj+M2LLyXHYGmCPeblsmmscP2Q==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "@mui/private-theming": "^5.15.6", + "@mui/styled-engine": "^5.15.6", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.6", + "clsx": "^2.1.0", "csstype": "^3.1.2", "prop-types": "^15.8.1" }, @@ -2100,9 +2288,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.11", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.11.tgz", - "integrity": "sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==", + "version": "7.2.13", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -2113,11 +2301,11 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.2.tgz", - "integrity": "sha512-6dGM9/guFKBlFRHA7/mbM+E7wE7CYDy9Ny4JLtD3J+NTyhi8nd8YxlzgAgTaTVqY0BpdQ2zdfB/q6+p2EdGM0w==", + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.6.tgz", + "integrity": "sha512-qfEhf+zfU9aQdbzo1qrSWlbPQhH1nCgeYgwhOVnj9Bn39shJQitEnXpSQpSNag8+uty5Od6PxmlNKPTnPySRKA==", "dependencies": { - "@babel/runtime": "^7.23.6", + "@babel/runtime": "^7.23.8", "@types/prop-types": "^15.7.11", "prop-types": "^15.8.1", "react-is": "^18.2.0" @@ -2243,6 +2431,16 @@ "node": ">=14.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2311,9 +2509,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.2.tgz", - "integrity": "sha512-RKzxFxBHq9ysZ83fn8Iduv3A283K7zPPYuhL/z9CQuyFrjwpErJx0h4aeb/bnJ+q29GRLgJpY66ceQ/Wcsn3wA==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", + "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", "cpu": [ "arm" ], @@ -2324,9 +2522,9 @@ "peer": true }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.2.tgz", - "integrity": "sha512-yZ+MUbnwf3SHNWQKJyWh88ii2HbuHCFQnAYTeeO1Nb8SyEiWASEi5dQUygt3ClHWtA9My9RQAYkjvrsZ0WK8Xg==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", + "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", "cpu": [ "arm64" ], @@ -2337,9 +2535,9 @@ "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.2.tgz", - "integrity": "sha512-vqJ/pAUh95FLc/G/3+xPqlSBgilPnauVf2EXOQCZzhZJCXDXt/5A8mH/OzU6iWhb3CNk5hPJrh8pqJUPldN5zw==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", + "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", "cpu": [ "arm64" ], @@ -2350,9 +2548,9 @@ "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.2.tgz", - "integrity": "sha512-otPHsN5LlvedOprd3SdfrRNhOahhVBwJpepVKUN58L0RnC29vOAej1vMEaVU6DadnpjivVsNTM5eNt0CcwTahw==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", + "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", "cpu": [ "x64" ], @@ -2363,9 +2561,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.2.tgz", - "integrity": "sha512-ewG5yJSp+zYKBYQLbd1CUA7b1lSfIdo9zJShNTyc2ZP1rcPrqyZcNlsHgs7v1zhgfdS+kW0p5frc0aVqhZCiYQ==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", + "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", "cpu": [ "arm" ], @@ -2376,9 +2574,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.2.tgz", - "integrity": "sha512-pL6QtV26W52aCWTG1IuFV3FMPL1m4wbsRG+qijIvgFO/VBsiXJjDPE/uiMdHBAO6YcpV4KvpKtd0v3WFbaxBtg==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", + "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", "cpu": [ "arm64" ], @@ -2389,9 +2587,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.2.tgz", - "integrity": "sha512-On+cc5EpOaTwPSNetHXBuqylDW+765G/oqB9xGmWU3npEhCh8xu0xqHGUA+4xwZLqBbIZNcBlKSIYfkBm6ko7g==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", + "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", "cpu": [ "arm64" ], @@ -2402,9 +2600,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.2.tgz", - "integrity": "sha512-Wnx/IVMSZ31D/cO9HSsU46FjrPWHqtdF8+0eyZ1zIB5a6hXaZXghUKpRrC4D5DcRTZOjml2oBhXoqfGYyXKipw==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", + "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", "cpu": [ "riscv64" ], @@ -2415,9 +2613,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.2.tgz", - "integrity": "sha512-ym5x1cj4mUAMBummxxRkI4pG5Vht1QMsJexwGP8547TZ0sox9fCLDHw9KCH9c1FO5d9GopvkaJsBIOkTKxksdw==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", + "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", "cpu": [ "x64" ], @@ -2428,9 +2626,9 @@ "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.2.tgz", - "integrity": "sha512-m0hYELHGXdYx64D6IDDg/1vOJEaiV8f1G/iO+tejvRCJNSwK4jJ15e38JQy5Q6dGkn1M/9KcyEOwqmlZ2kqaZg==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", + "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", "cpu": [ "x64" ], @@ -2441,9 +2639,9 @@ "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.2.tgz", - "integrity": "sha512-x1CWburlbN5JjG+juenuNa4KdedBdXLjZMp56nHFSHTOsb/MI2DYiGzLtRGHNMyydPGffGId+VgjOMrcltOksA==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", + "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", "cpu": [ "arm64" ], @@ -2454,9 +2652,9 @@ "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.2.tgz", - "integrity": "sha512-VVzCB5yXR1QlfsH1Xw1zdzQ4Pxuzv+CPr5qpElpKhVxlxD3CRdfubAG9mJROl6/dmj5gVYDDWk8sC+j9BI9/kQ==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", + "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", "cpu": [ "ia32" ], @@ -2467,9 +2665,9 @@ "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.2.tgz", - "integrity": "sha512-SYRedJi+mweatroB+6TTnJYLts0L0bosg531xnQWtklOI6dezEagx4Q0qDyvRdK+qgdA3YZpjjGuPFtxBmddBA==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", + "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", "cpu": [ "x64" ], @@ -2480,109 +2678,132 @@ "peer": true }, "node_modules/@sentry-internal/feedback": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.91.0.tgz", - "integrity": "sha512-SJKTSaz68F5YIwF79EttBm915M2LnacgZMYRnRumyTmMKnebGhYQLwWbZdpaDvOa1U18dgRajDX8Qed/8A3tXw==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.98.0.tgz", + "integrity": "sha512-t/mATvwkLcQLKRlx8SO5vlUjaadF6sT3lfR0PdWYyBy8qglbMTHDW4KP6JKh1gdzTVQGnwMByy+/4h9gy4AVzw==", "dependencies": { - "@sentry/core": "7.91.0", - "@sentry/types": "7.91.0", - "@sentry/utils": "7.91.0" + "@sentry/core": "7.98.0", + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry-internal/feedback/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } }, - "node_modules/@sentry-internal/tracing": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.91.0.tgz", - "integrity": "sha512-JH5y6gs6BS0its7WF2DhySu7nkhPDfZcdpAXldxzIlJpqFkuwQKLU5nkYJpiIyZz1NHYYtW5aum2bV2oCOdDRA==", + "node_modules/@sentry-internal/replay-canvas": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.98.0.tgz", + "integrity": "sha512-vAR6KIycyazaY9HwxG5UONrPTe8jeKtZr6k04svPC8OvcoI0xF+l1jMEYcarffuzKpZlPfssYb5ChHtKuXCB+Q==", "dependencies": { - "@sentry/core": "7.91.0", - "@sentry/types": "7.91.0", - "@sentry/utils": "7.91.0" + "@sentry/core": "7.98.0", + "@sentry/replay": "7.98.0", + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/@sentry-internal/tracing/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/types": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } }, - "node_modules/@sentry/browser": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.91.0.tgz", - "integrity": "sha512-lJv3x/xekzC/biiyAsVCioq2XnKNOZhI6jY3ZzLJZClYV8eKRi7D3KCsHRvMiCdGak1d/6sVp8F4NYY+YiWy1Q==", + "node_modules/@sentry-internal/tracing": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.98.0.tgz", + "integrity": "sha512-FnhD2uMLIAJvv4XsYPv3qsTTtxrImyLxiZacudJyaWFhxoeVQ8bKKbWJ/Ar68FAwqTtjXMeY5evnEBbRMcQlaA==", "dependencies": { - "@sentry-internal/feedback": "7.91.0", - "@sentry-internal/tracing": "7.91.0", - "@sentry/core": "7.91.0", - "@sentry/replay": "7.91.0", - "@sentry/types": "7.91.0", - "@sentry/utils": "7.91.0" + "@sentry/core": "7.98.0", + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" }, "engines": { "node": ">=8" } }, - "node_modules/@sentry/browser/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "node_modules/@sentry-internal/tracing/node_modules/@sentry/types": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.98.0.tgz", + "integrity": "sha512-/MzTS31N2iM6Qwyh4PSpHihgmkVD5xdfE5qi1mTlwQZz5Yz8t7MdMriX8bEDPlLB8sNxl7+D6/+KUJO8akX0nQ==", + "dependencies": { + "@sentry-internal/feedback": "7.98.0", + "@sentry-internal/replay-canvas": "7.98.0", + "@sentry-internal/tracing": "7.98.0", + "@sentry/core": "7.98.0", + "@sentry/replay": "7.98.0", + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/types": { + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } }, "node_modules/@sentry/core": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.91.0.tgz", - "integrity": "sha512-tu+gYq4JrTdrR+YSh5IVHF0fJi/Pi9y0HZ5H9HnYy+UMcXIotxf6hIEaC6ZKGeLWkGXffz2gKpQLe/g6vy/lPA==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.98.0.tgz", + "integrity": "sha512-baRUcpCNGyk7cApQHMfqEZJkXdvAKK+z/dVWiMqWc5T5uhzMnPE8/gjP1JZsMtJSQ8g5nHimBdI5TwOyZtxPaA==", "dependencies": { - "@sentry/types": "7.91.0", - "@sentry/utils": "7.91.0" + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/core/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } }, "node_modules/@sentry/replay": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.91.0.tgz", - "integrity": "sha512-XwbesnLLNtaVXKtDoyBB96GxJuhGi9zy3a662Ba/McmumCnkXrMQYpQPh08U7MgkTyDRgjDwm7PXDhiKpcb03g==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.98.0.tgz", + "integrity": "sha512-CQabv/3KnpMkpc2TzIquPu5krpjeMRAaDIO0OpTj5SQeH2RqSq3fVWNZkHa8tLsADxcfLFINxqOg2jd1NxvwxA==", "dependencies": { - "@sentry-internal/tracing": "7.91.0", - "@sentry/core": "7.91.0", - "@sentry/types": "7.91.0", - "@sentry/utils": "7.91.0" + "@sentry-internal/tracing": "7.98.0", + "@sentry/core": "7.98.0", + "@sentry/types": "7.98.0", + "@sentry/utils": "7.98.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry/replay/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } @@ -2597,20 +2818,20 @@ } }, "node_modules/@sentry/utils": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.91.0.tgz", - "integrity": "sha512-fvxjrEbk6T6Otu++Ax9ntlQ0sGRiwSC179w68aC3u26Wr30FAIRKqHTCCdc2jyWk7Gd9uWRT/cq+g8NG/8BfSg==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.98.0.tgz", + "integrity": "sha512-0/LY+kpHxItVRY0xPDXPXVsKRb95cXsGSQf8sVMtfSjz++0bLL1U4k7PFz1c5s2/Vk0B8hS6duRrgMv6dMIZDw==", "dependencies": { - "@sentry/types": "7.91.0" + "@sentry/types": "7.98.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/utils/node_modules/@sentry/types": { - "version": "7.91.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.91.0.tgz", - "integrity": "sha512-bcQnb7J3P3equbCUc+sPuHog2Y47yGD2sCkzmnZBjvBT0Z1B4f36fI/5WjyZhTjLSiOdg3F2otwvikbMjmBDew==", + "version": "7.98.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.98.0.tgz", + "integrity": "sha512-pc034ziM0VTETue4bfBcBqTWGy4w0okidtoZJjGVrYAfE95ObZnUGVj/XYIQ3FeCYWIa7NFN2MvdsCS0buwivQ==", "engines": { "node": ">=8" } @@ -2620,177 +2841,492 @@ "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-6.1.2.tgz", "integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ==" }, - "node_modules/@testing-library/dom": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", - "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", "dev": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, "engines": { "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", "dev": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/dom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", "dev": true, - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, "engines": { - "node": ">=7.0.0" + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", "dev": true, - "peer": true + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@testing-library/dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", "dev": true, - "peer": true, "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/dom/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", "dev": true, - "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" - }, "engines": { - "node": ">=8", - "npm": ">=6", - "yarn": ">=1" + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" }, "engines": { - "node": ">=8" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" }, "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@svgr/core/node_modules/@babel/core": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", + "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.9", + "@babel/parser": "^7.23.9", + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": ">=7.0.0" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@testing-library/jest-dom/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@testing-library/jest-dom/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@svgr/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@svgr/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@svgr/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-jsx/node_modules/@babel/core": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", + "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.9", + "@babel/parser": "^7.23.9", + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@svgr/plugin-jsx/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@svgr/plugin-jsx/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "peer": true + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" } }, "node_modules/@testing-library/jest-dom/node_modules/supports-color": { @@ -2928,6 +3464,16 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tginternal/editor": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@tginternal/editor/-/editor-1.13.4.tgz", + "integrity": "sha512-7aBbjXkoHop1tuVztks4ttqrAljeP/vOwVyHJlTXhGCNUcPKw5Xg1FLpH85lGKyqdXtI1KFBunGwY0BGlN4Ayw==", + "peerDependencies": { + "@codemirror/lint": "^6.4.2", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.23.1" + } + }, "node_modules/@tginternal/language-util": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@tginternal/language-util/-/language-util-1.0.6.tgz", @@ -2939,22 +3485,22 @@ } }, "node_modules/@tolgee/cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@tolgee/cli/-/cli-1.0.1.tgz", - "integrity": "sha512-Iq3UilrQTTuWqLoExnP8Fy3BWm5O9zE/DQTNTSfrVkukkd1r6z9w8GA9RP25PP9AWQIyDcLQ4Sn2fhddZOrwMg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@tolgee/cli/-/cli-1.4.0.tgz", + "integrity": "sha512-VgmeEH+lOHuQTcIUVhUKR8M4JXRMg2s/qcnntmRKfBTFkRn29e6dKuzYS0WogsIb1k5lRY13C/FqHZFf4TtPJQ==", "dev": true, "dependencies": { "ansi-colors": "^4.1.3", "base32-decode": "^1.0.0", - "commander": "^10.0.0", - "cosmiconfig": "^8.0.0", + "commander": "^11.0.0", + "cosmiconfig": "^8.2.0", "form-data": "^4.0.0", - "glob": "^8.1.0", + "glob": "^10.3.3", "json5": "^2.2.3", - "undici": "^5.15.0", + "undici": "^5.22.1", "vscode-oniguruma": "^1.7.0", - "vscode-textmate": "^8.0.0", - "xstate": "^4.35.2", + "vscode-textmate": "^9.0.0", + "xstate": "^4.38.1", "yauzl": "^2.10.0" }, "bin": { @@ -2964,77 +3510,33 @@ "node": ">= 18" } }, - "node_modules/@tolgee/cli/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/@tolgee/cli/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@tolgee/cli/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@tolgee/core": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/@tolgee/core/-/core-5.19.0.tgz", - "integrity": "sha512-5zOkxjtxUBEC8xTWtYU5r6bvwHK80s95zH4Y4uLkTcIYTRvn4kY7Z5/Hg5/hyIApA6P8taOrNy/m5favxCmREQ==" + "version": "5.19.3", + "resolved": "https://registry.npmjs.org/@tolgee/core/-/core-5.19.3.tgz", + "integrity": "sha512-pq1MDvRH/WFpSI4JQkTLCqLV2ejqQZEIieFkxJwHCZWheQqsnEDJs9y9XMpg3nflkSZTfInM4nS6YzqtYLJk4w==" }, "node_modules/@tolgee/format-icu": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/@tolgee/format-icu/-/format-icu-5.19.0.tgz", - "integrity": "sha512-hV8MJEFrN90nAWNWHsz7RZy7S/23cIuETZ3exKGlu7m397BTtqT/52eE3bh4lcAeKwvhfF+v/hWT+vZrkNxmwA==" + "version": "5.19.3", + "resolved": "https://registry.npmjs.org/@tolgee/format-icu/-/format-icu-5.19.3.tgz", + "integrity": "sha512-R7CTGy6JwukOVKkHZ4JBDKUjpT8H6DbkxXGA8bnRQlDZPR1bPVVDRQcqm1FzwOZQNCXZ+5krDzwKsp0CyA11tQ==" }, "node_modules/@tolgee/react": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/@tolgee/react/-/react-5.19.0.tgz", - "integrity": "sha512-FYQyymza7NQ/aqPZqpm/7ErHI7T8QeptbjKDSekomaN6nxifumSzYpuvjhBX+y2o/z1HGdqWZR9sc1IJU0VAwQ==", + "version": "5.19.3", + "resolved": "https://registry.npmjs.org/@tolgee/react/-/react-5.19.3.tgz", + "integrity": "sha512-Ve2jwKyS8DuZ1Etr+8bSGxoWO+QdoVAGxXlS4E090ksZHLxwaQzFLWED2lQhGI+53dmMAK24OgBQYgIs6Gx5Hg==", "dependencies": { - "@tolgee/web": "5.19.0" + "@tolgee/web": "5.19.3" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.1 || ^18.1.0" } }, "node_modules/@tolgee/web": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/@tolgee/web/-/web-5.19.0.tgz", - "integrity": "sha512-a57howuvHz6KGDg8wu6K1KxuBlTUD/AV0+1UKj+2xiFECNQSqwP8/bGO+9Hb9xkNqVxx+l1R13xK9XSZMGXNhw==", + "version": "5.19.3", + "resolved": "https://registry.npmjs.org/@tolgee/web/-/web-5.19.3.tgz", + "integrity": "sha512-2gXKR1Z36e1iodSY+V7U7khwpuPjssCnLUTpAJaOpvpD1qv57B+yMch8/Kpw9Cvmxk/DVqoPTQxfBGidbzNU4A==", "dependencies": { - "@tolgee/core": "5.19.0" + "@tolgee/core": "5.19.3" } }, "node_modules/@types/acorn": { @@ -3088,15 +3590,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/codemirror": { - "version": "5.60.15", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz", - "integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==", - "dev": true, - "dependencies": { - "@types/tern": "*" - } - }, "node_modules/@types/d3-color": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz", @@ -3315,9 +3808,9 @@ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/node": { - "version": "18.19.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.4.tgz", - "integrity": "sha512-xNzlUhzoHotIsnFoXmJB+yWmBvFZgKCI9TtPIEdYIMM1KWfwuY8zh7wvc1u1OAXlC7dlf6mZVx/s+Y5KfFz19A==", + "version": "18.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.10.tgz", + "integrity": "sha512-IZD8kAM02AW1HRDTPOlz3npFava678pr8Ie9Vp8uRhBROXAv8MXT2pCnGZZAKYdromsNQLHQcfWQ6EOatVLtqA==", "devOptional": true, "dependencies": { "undici-types": "~5.26.4" @@ -3346,9 +3839,9 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { - "version": "17.0.74", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.74.tgz", - "integrity": "sha512-nBtFGaeTMzpiL/p73xbmCi00SiCQZDTJUk9ZuHOLtil3nI+y7l269LHkHIAYpav99ZwGnPJzuJsJpfLXjiQ52g==", + "version": "17.0.75", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.75.tgz", + "integrity": "sha512-MSA+NzEzXnQKrqpO63CYqNstFjsESgvJAdAyyJ1n6ZQq/GLgf6nOfIKwk+Twuz0L1N6xPe+qz5xRCJrbhMaLsw==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3429,15 +3922,6 @@ "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", "dev": true }, - "node_modules/@types/tern": { - "version": "0.23.9", - "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", - "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "dev": true, - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", @@ -3480,16 +3964,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.17.0.tgz", - "integrity": "sha512-Vih/4xLXmY7V490dGwBQJTpIZxH4ZFH6eCVmQ4RFkB+wmaCTDAx4dtgoWwMNGKLkqRY1L6rPqzEbjorRnDo4rQ==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.19.1.tgz", + "integrity": "sha512-roQScUGFruWod9CEyoV5KlCYrubC/fvG8/1zXuT0WTcxX87GnMMmnksMwSg99lo1xiKrBzw2icsJPMAw1OtKxg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.17.0", - "@typescript-eslint/type-utils": "6.17.0", - "@typescript-eslint/utils": "6.17.0", - "@typescript-eslint/visitor-keys": "6.17.0", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/type-utils": "6.19.1", + "@typescript-eslint/utils": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -3514,18 +3998,6 @@ } } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -3541,22 +4013,16 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/parser": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.17.0.tgz", - "integrity": "sha512-C4bBaX2orvhK+LlwrY8oWGmSl4WolCfYm513gEccdWZj0CwGadbIADb0FtVEcI+WzUyjyoBj2JRP8g25E6IB8A==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.19.1.tgz", + "integrity": "sha512-WEfX22ziAh6pRE9jnbkkLGp/4RhTpffr2ZK5bJ18M8mIfA8A+k97U9ZyaXCEJRlmMHh7R9MJZWXp/r73DzINVQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.17.0", - "@typescript-eslint/types": "6.17.0", - "@typescript-eslint/typescript-estree": "6.17.0", - "@typescript-eslint/visitor-keys": "6.17.0", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", "debug": "^4.3.4" }, "engines": { @@ -3576,13 +4042,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.17.0.tgz", - "integrity": "sha512-RX7a8lwgOi7am0k17NUO0+ZmMOX4PpjLtLRgLmT1d3lBYdWH4ssBUbwdmc5pdRX8rXon8v9x8vaoOSpkHfcXGA==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.1.tgz", + "integrity": "sha512-4CdXYjKf6/6aKNMSly/BP4iCSOpvMmqtDzRtqFyyAae3z5kkqEjKndR5vDHL8rSuMIIWP8u4Mw4VxLyxZW6D5w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.17.0", - "@typescript-eslint/visitor-keys": "6.17.0" + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -3593,13 +4059,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.17.0.tgz", - "integrity": "sha512-hDXcWmnbtn4P2B37ka3nil3yi3VCQO2QEB9gBiHJmQp5wmyQWqnjA85+ZcE8c4FqnaB6lBwMrPkgd4aBYz3iNg==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.1.tgz", + "integrity": "sha512-0vdyld3ecfxJuddDjACUvlAeYNrHP/pDeQk2pWBR2ESeEzQhg52DF53AbI9QCBkYE23lgkhLCZNkHn2hEXXYIg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.17.0", - "@typescript-eslint/utils": "6.17.0", + "@typescript-eslint/typescript-estree": "6.19.1", + "@typescript-eslint/utils": "6.19.1", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -3620,9 +4086,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.17.0.tgz", - "integrity": "sha512-qRKs9tvc3a4RBcL/9PXtKSehI/q8wuU9xYJxe97WFxnzH8NWWtcW3ffNS+EWg8uPvIerhjsEZ+rHtDqOCiH57A==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.1.tgz", + "integrity": "sha512-6+bk6FEtBhvfYvpHsDgAL3uo4BfvnTnoge5LrrCj2eJN8g3IJdLTD4B/jK3Q6vo4Ql/Hoip9I8aB6fF+6RfDqg==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -3633,13 +4099,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.17.0.tgz", - "integrity": "sha512-gVQe+SLdNPfjlJn5VNGhlOhrXz4cajwFd5kAgWtZ9dCZf4XJf8xmgCTLIqec7aha3JwgLI2CK6GY1043FRxZwg==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.1.tgz", + "integrity": "sha512-aFdAxuhzBFRWhy+H20nYu19+Km+gFfwNO4TEqyszkMcgBDYQjmPJ61erHxuT2ESJXhlhrO7I5EFIlZ+qGR8oVA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.17.0", - "@typescript-eslint/visitor-keys": "6.17.0", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/visitor-keys": "6.19.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -3660,42 +4126,6 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -3711,24 +4141,18 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/utils": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.17.0.tgz", - "integrity": "sha512-LofsSPjN/ITNkzV47hxas2JCsNCEnGhVvocfyOcLzT9c/tSZE7SfhS/iWtzP1lKNOEfLhRTZz6xqI8N2RzweSQ==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.1.tgz", + "integrity": "sha512-JvjfEZuP5WoMqwh9SPAPDSHSg9FBHHGhjPugSRxu5jMfjvBpq5/sGTD+9M9aQ5sh6iJ8AY/Kk/oUYVEMAPwi7w==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.17.0", - "@typescript-eslint/types": "6.17.0", - "@typescript-eslint/typescript-estree": "6.17.0", + "@typescript-eslint/scope-manager": "6.19.1", + "@typescript-eslint/types": "6.19.1", + "@typescript-eslint/typescript-estree": "6.19.1", "semver": "^7.5.4" }, "engines": { @@ -3742,18 +4166,6 @@ "eslint": "^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -3769,19 +4181,13 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.17.0.tgz", - "integrity": "sha512-H6VwB/k3IuIeQOyYczyyKN8wH6ed8EwliaYHLxOIhyF0dYEIsN8+Bk3GE19qafeMKyZJJHP8+O1HiFhFLUNKSg==", + "version": "6.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.1.tgz", + "integrity": "sha512-gkdtIO+xSO/SmI0W68DBg4u1KElmIUo3vXzgHyGPs6cxgB0sa3TlptRAAE0hUY1hM6FcDKEv7aIwiTGm76cXfQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.17.0", + "@typescript-eslint/types": "6.19.1", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -3815,6 +4221,48 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/core": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", + "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.9", + "@babel/parser": "^7.23.9", + "@babel/template": "^7.23.9", + "@babel/traverse": "^7.23.9", + "@babel/types": "^7.23.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "node_modules/@vitejs/plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -3879,15 +4327,25 @@ "node": ">=4" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "dependencies": { - "sprintf-js": "~1.0.2" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", @@ -4148,6 +4606,21 @@ "npm": ">=6" } }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -4206,6 +4679,15 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", @@ -4213,12 +4695,24 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/broadcast-channel": { @@ -4318,40 +4812,6 @@ "node": ">= 4" } }, - "node_modules/browserify-sign/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/browserify-sign/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/browserify-zlib": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", @@ -4362,9 +4822,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", + "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", "funding": [ { "type": "opencollective", @@ -4380,8 +4840,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001580", + "electron-to-chromium": "^1.4.648", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -4425,13 +4885,6 @@ "node": "*" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "optional": true, - "peer": true - }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -4502,9 +4955,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001572", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", - "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==", + "version": "1.0.30001581", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz", + "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==", "funding": [ { "type": "opencollective", @@ -4590,8 +5043,35 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/cipher-base": { - "version": "1.0.4", + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cipher-base": { + "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", "dev": true, @@ -4614,9 +5094,18 @@ } }, "node_modules/codemirror": { - "version": "5.65.16", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.16.tgz", - "integrity": "sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg==" + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } }, "node_modules/collapse-white-space": { "version": "1.0.6", @@ -4664,12 +5153,12 @@ } }, "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "dev": true, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/concat-map": { @@ -4690,9 +5179,9 @@ "dev": true }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/copy-to-clipboard": { "version": "3.3.3", @@ -4703,18 +5192,29 @@ } }, "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/create-ecdh": { @@ -4766,6 +5266,11 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cropperjs": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.1.tgz", @@ -5036,6 +5541,22 @@ "node": ">=0.10.0" } }, + "node_modules/deep-rename-keys/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "node_modules/deep-rename-keys/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", @@ -5238,9 +5759,9 @@ } }, "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", + "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", "engines": { "node": ">=12" }, @@ -5259,10 +5780,16 @@ "node": ">= 12.0.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/electron-to-chromium": { - "version": "1.4.616", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz", - "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==" + "version": "1.4.648", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.648.tgz", + "integrity": "sha512-EmFMarXeqJp9cUKu/QEciEApn0S/xRcpZWuAm32U7NgoZCimjsilKXHRO9saeEW55eHZagIDg6XTUOv32w9pjg==" }, "node_modules/elliptic": { "version": "6.5.4", @@ -5285,6 +5812,12 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", @@ -5307,6 +5840,18 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5459,9 +6004,9 @@ } }, "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "peer": true, "bin": { @@ -5471,29 +6016,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" } }, "node_modules/escalade": { @@ -5623,6 +6168,16 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5635,6 +6190,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -5652,6 +6219,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -5734,6 +6310,25 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/eslint/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5810,16 +6405,29 @@ "node": ">= 4" } }, - "node_modules/eslint/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/eslint/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=10" + "node": "*" } }, "node_modules/eslint/node_modules/semver": { @@ -5861,12 +6469,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/espree": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", @@ -6119,9 +6721,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", - "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.0.tgz", + "integrity": "sha512-zGygtijUMT7jnk3h26kUms3BkSDp4IfIKjmnqI2tvx6nuBfiF1UqOxbnLfzdv+apBy+53oaImsKtMw/xYbW+1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -6186,11 +6788,36 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -6220,6 +6847,22 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -6269,11 +6912,38 @@ "react": ">=16.8.0" } }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6355,19 +7025,22 @@ } }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6385,33 +7058,12 @@ "node": ">= 6" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "engines": { - "node": ">=4" + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" } }, "node_modules/globalthis": { @@ -6576,40 +7228,6 @@ "node": ">=4" } }, - "node_modules/hash-base/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/hash-base/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -6668,29 +7286,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-from-parse5/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/hast-util-from-parse5/node_modules/unist-util-stringify-position": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", @@ -6788,29 +7383,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/hast-util-raw/node_modules/unist-util-stringify-position": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", @@ -6916,9 +7488,9 @@ } }, "node_modules/hast-util-to-estree/node_modules/property-information": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz", - "integrity": "sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.1.tgz", + "integrity": "sha512-OHYtXfu5aI2sS2LWFSN5rgJjrQ4pCy8i1jubJLe2QvMF8JJ++HXTUIVWFLfXJoaOfvYYjk2SN8J2wFUWIGXT4w==", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -7028,9 +7600,9 @@ "integrity": "sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ==" }, "node_modules/hast-util-to-jsx-runtime/node_modules/property-information": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz", - "integrity": "sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.1.tgz", + "integrity": "sha512-OHYtXfu5aI2sS2LWFSN5rgJjrQ4pCy8i1jubJLe2QvMF8JJ++HXTUIVWFLfXJoaOfvYYjk2SN8J2wFUWIGXT4w==", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -7197,24 +7769,6 @@ "node": ">=10" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/html-void-elements": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-1.0.5.tgz", @@ -7265,17 +7819,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", - "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7485,6 +8028,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -7502,9 +8057,26 @@ } }, "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } }, "node_modules/is-callable": { "version": "1.2.7", @@ -7658,6 +8230,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", @@ -7860,6 +8441,24 @@ "set-function-name": "^2.0.1" } }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-diff": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", @@ -7986,13 +8585,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -8080,12 +8678,10 @@ } }, "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dependencies": { - "is-buffer": "^1.1.5" - }, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8136,6 +8732,18 @@ "node": ">=8.9.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -8146,6 +8754,11 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8218,11 +8831,15 @@ } }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { - "yallist": "^3.0.2" + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, "node_modules/lz-string": { @@ -8280,12 +8897,12 @@ } }, "node_modules/match-sorter": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", - "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.3.tgz", + "integrity": "sha512-sgiXxrRijEe0SzHKGX4HouCpfHRPnqteH42UdMEW7BlWy990ZkzcvonJGv4Uu9WE7Y1f8Yocm91+4qFPCbmNww==", "dependencies": { - "@babel/runtime": "^7.12.5", - "remove-accents": "0.4.2" + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" } }, "node_modules/md5.js": { @@ -9142,9 +9759,9 @@ } }, "node_modules/micromark-util-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.0.1.tgz", - "integrity": "sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", "funding": [ { "type": "GitHub Sponsors", @@ -9436,51 +10053,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/micromatch/node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/micromatch/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/micromatch/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/microseconds": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", @@ -9560,14 +10132,18 @@ "dev": true }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -9593,13 +10169,13 @@ "node": ">= 6" } }, - "node_modules/minimist-options/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mri": { @@ -9740,93 +10316,12 @@ "node": ">=10" } }, - "node_modules/node-stdlib-browser/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/node-stdlib-browser/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/node-stdlib-browser/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/node-stdlib-browser/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-stdlib-browser/node_modules/pkg-dir": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dev": true, - "dependencies": { - "find-up": "^5.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-stdlib-browser/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, - "node_modules/node-stdlib-browser/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -9854,18 +10349,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-package-data/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -9881,12 +10364,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -10082,24 +10559,6 @@ "npm": ">= 7.0.0" } }, - "node_modules/openapi-typescript/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/openapi-typescript/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/openapi-typescript/node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10120,28 +10579,6 @@ } } }, - "node_modules/openapi-typescript/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, - "node_modules/openapi-typescript/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - }, - "node_modules/openapi-typescript/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -10166,20 +10603,32 @@ "dev": true }, "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "dependencies": { - "yocto-queue": "^0.1.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -10266,6 +10715,15 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -10288,6 +10746,31 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dev": true, + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/path-to-regexp": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", @@ -10349,6 +10832,11 @@ "@types/estree": "^1.0.0" } }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -10360,69 +10848,185 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/posthog-js": { - "version": "1.96.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.96.1.tgz", - "integrity": "sha512-kv1vQqYMt2BV3YHS+wxsbGuP+tz+M3y1AzNhz8TfkpY1HT8W/ONT0i0eQpeRr9Y+d4x/fZ6M4cXG5GMvi9lRCA==", - "dependencies": { - "fflate": "^0.4.1" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", "dev": true, + "dependencies": { + "find-up": "^5.0.0" + }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "node_modules/pkg-dir/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "bin": { - "prettier": "bin-prettier.js" + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "dependencies": { - "fast-diff": "^1.1.2" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "yocto-queue": "^0.1.0" }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" + }, + "node_modules/posthog-js": { + "version": "1.103.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.103.1.tgz", + "integrity": "sha512-cFXFU4Z4kl/+RUUV4ju1DlfM7dwCGi6H9xWsfhljIhGcBbT8UfS4JGgZGXl9ABQDdgDPb9xciqnysFSsUQshTA==", + "dependencies": { + "fflate": "^0.4.8", + "preact": "^10.19.3" + } + }, + "node_modules/preact": { + "version": "10.19.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", + "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -10628,15 +11232,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-codemirror2": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/react-codemirror2/-/react-codemirror2-7.3.0.tgz", - "integrity": "sha512-gCgJPXDX+5iaPolkHAu1YbJ92a2yL7Je4TuyO3QEqOtI/d6mbEk08l0oIm18R4ctuT/Sl87X63xIMBnRQBXYXA==", - "peerDependencies": { - "codemirror": "5.x", - "react": ">=15.5 <=17.x" - } - }, "node_modules/react-cropper": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/react-cropper/-/react-cropper-2.1.8.tgz", @@ -10830,28 +11425,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/react-markdown/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/react-markdown/node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -11320,9 +11893,9 @@ ] }, "node_modules/react-markdown/node_modules/property-information": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz", - "integrity": "sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.1.tgz", + "integrity": "sha512-OHYtXfu5aI2sS2LWFSN5rgJjrQ4pCy8i1jubJLe2QvMF8JJ++HXTUIVWFLfXJoaOfvYYjk2SN8J2wFUWIGXT4w==", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -11692,67 +12265,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -11780,15 +12292,6 @@ "validate-npm-package-license": "^3.0.1" } }, - "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", @@ -11798,6 +12301,32 @@ "node": ">=8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/recharts": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.9.tgz", @@ -11861,11 +12390,6 @@ "postcss-value-parser": "^3.3.0" } }, - "node_modules/reduce-css-calc/node_modules/postcss-value-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" - }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -12063,62 +12587,16 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/remark-mdx/node_modules/@babel/core": { - "version": "7.12.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", - "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.12.5", - "@babel/helper-module-transforms": "^7.12.1", - "@babel/helpers": "^7.12.5", - "@babel/parser": "^7.12.7", - "@babel/template": "^7.12.7", - "@babel/traverse": "^7.12.9", - "@babel/types": "^7.12.7", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.1", - "json5": "^2.1.2", - "lodash": "^4.17.19", - "resolve": "^1.3.2", - "semver": "^5.4.1", - "source-map": "^0.5.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/remark-mdx/node_modules/@babel/helper-plugin-utils": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", - "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", - "dev": true - }, - "node_modules/remark-mdx/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, - "node_modules/remark-mdx/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/remark-parse": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", - "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==", + "node_modules/remark-mdx/node_modules/@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "node_modules/remark-parse": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", + "integrity": "sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==", "dev": true, "dependencies": { "ccount": "^1.0.0", @@ -12167,28 +12645,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/remark-rehype/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/remark-rehype/node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -12458,9 +12914,9 @@ } }, "node_modules/remove-accents": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", - "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" }, "node_modules/rename-keys": { "version": "1.2.0", @@ -12559,6 +13015,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12578,6 +13043,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", @@ -12589,10 +13065,13 @@ } }, "node_modules/rollup": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.2.tgz", - "integrity": "sha512-66RB8OtFKUTozmVEh3qyNfH+b+z2RXBVloqO2KCC/pjFaGaHtxP9fVfOQKPSGXg2mElmjmxjW/fZ7iKrEpMH5Q==", + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", + "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", "peer": true, + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, @@ -12601,36 +13080,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.2", - "@rollup/rollup-android-arm64": "4.9.2", - "@rollup/rollup-darwin-arm64": "4.9.2", - "@rollup/rollup-darwin-x64": "4.9.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.2", - "@rollup/rollup-linux-arm64-gnu": "4.9.2", - "@rollup/rollup-linux-arm64-musl": "4.9.2", - "@rollup/rollup-linux-riscv64-gnu": "4.9.2", - "@rollup/rollup-linux-x64-gnu": "4.9.2", - "@rollup/rollup-linux-x64-musl": "4.9.2", - "@rollup/rollup-win32-arm64-msvc": "4.9.2", - "@rollup/rollup-win32-ia32-msvc": "4.9.2", - "@rollup/rollup-win32-x64-msvc": "4.9.2", + "@rollup/rollup-android-arm-eabi": "4.9.6", + "@rollup/rollup-android-arm64": "4.9.6", + "@rollup/rollup-darwin-arm64": "4.9.6", + "@rollup/rollup-darwin-x64": "4.9.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", + "@rollup/rollup-linux-arm64-gnu": "4.9.6", + "@rollup/rollup-linux-arm64-musl": "4.9.6", + "@rollup/rollup-linux-riscv64-gnu": "4.9.6", + "@rollup/rollup-linux-x64-gnu": "4.9.6", + "@rollup/rollup-linux-x64-musl": "4.9.6", + "@rollup/rollup-win32-arm64-msvc": "4.9.6", + "@rollup/rollup-win32-ia32-msvc": "4.9.6", + "@rollup/rollup-win32-x64-msvc": "4.9.6", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12666,13 +13131,13 @@ } }, "node_modules/safe-array-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", + "integrity": "sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -12684,20 +13149,37 @@ } }, "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.5", + "get-intrinsic": "^1.2.2", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12718,23 +13200,24 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "bin": { - "semver": "bin/semver.js" + "semver": "bin/semver" } }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", + "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", "dev": true, "dependencies": { "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.2", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -12808,6 +13291,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12920,27 +13415,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", @@ -12962,9 +13436,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.4.0.tgz", + "integrity": "sha512-hcjppoJ68fhxA/cjbN4T8N6uCUejN8yFw69ttpqtBeCbF3u13n7mb31NB9jKwGTTWWnt9IbRA/mf1FprYS8wfw==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -13026,20 +13500,6 @@ "readable-stream": "^3.5.0" } }, - "node_modules/stream-browserify/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/stream-http": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", @@ -13052,27 +13512,78 @@ "xtend": "^4.0.2" } }, - "node_modules/stream-http/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">= 6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, "dependencies": { - "safe-buffer": "~5.1.0" + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/string.prototype.matchall": { @@ -13174,6 +13685,28 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -13198,6 +13731,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" + }, "node_modules/style-to-object": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", @@ -13307,45 +13845,6 @@ "node": ">=8" } }, - "node_modules/terser": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", - "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13392,6 +13891,18 @@ "node": ">=4" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -13402,6 +13913,12 @@ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/trim": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", @@ -13554,18 +14071,18 @@ } }, "node_modules/tsconfck": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.1.2.tgz", - "integrity": "sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.0.1.tgz", + "integrity": "sha512-7ppiBlF3UEddCLeI1JRx5m2Ryq+xk4JrZuq4EuYXykipebaq1dV0Fhgr1hb7CkmHt32QSgOZlcqVLEtHBG4/mg==", "dev": true, "bin": { "tsconfck": "bin/tsconfck.js" }, "engines": { - "node": "^14.13.1 || ^16 || >=18" + "node": "^18 || >=20" }, "peerDependencies": { - "typescript": "^4.3.5 || ^5.0.0" + "typescript": "^5.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -13597,15 +14114,6 @@ "json5": "lib/cli.js" } }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -13784,29 +14292,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unified/node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/unified/node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -14068,11 +14553,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/update-browserslist-db/node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14145,6 +14625,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/usehooks-ts": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.12.1.tgz", + "integrity": "sha512-meo93qn2hyBJdHVczbalnsU2FU2WQ1ZVRmppRn8+P6TXo9hORNe10pFVKJfIBYfb2FFapqNuF5vUviLRSy/vAw==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -14258,550 +14752,117 @@ "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, "node_modules/vite": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz", - "integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", + "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==", "peer": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-node-polyfills": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.19.0.tgz", - "integrity": "sha512-AhdVxAmVnd1doUlIRGUGV6ZRPfB9BvIwDF10oCOmL742IsvsFIAV4tSMxSfu5e0Px0QeJLgWVOSbtHIvblzqMw==", - "dev": true, - "dependencies": { - "@rollup/plugin-inject": "^5.0.5", - "node-stdlib-browser": "^1.2.0" - }, - "funding": { - "url": "https://github.com/sponsors/davidmyersdev" - }, - "peerDependencies": { - "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" - } - }, - "node_modules/vite-plugin-static-copy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-1.0.0.tgz", - "integrity": "sha512-kMlrB3WDtC5GzFedNIPkpjnOAr8M11PfWOiUaONrUZ3AqogTsOmIhTt6w7Fh311wl8pN81ld7sfuOEogFJ9N8A==", - "dev": true, - "dependencies": { - "chokidar": "^3.5.3", - "fast-glob": "^3.2.11", - "fs-extra": "^11.1.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/vite-plugin-static-copy/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/vite-plugin-static-copy/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/vite-plugin-svgr": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", - "integrity": "sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.5", - "@svgr/core": "^8.1.0", - "@svgr/plugin-jsx": "^8.1.0" - }, - "peerDependencies": { - "vite": "^2.6.0 || 3 || 4 || 5" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "dev": true, - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/vite-plugin-svgr/node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/vite-plugin-svgr/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/vite-plugin-svgr/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vite-plugin-svgr/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "esbuild": "^0.19.3", + "postcss": "^8.4.32", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=14" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/d-fischer" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "typescript": ">=4.9.5" + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { - "typescript": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true } } }, - "node_modules/vite-plugin-svgr/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/vite-plugin-node-polyfills": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.19.0.tgz", + "integrity": "sha512-AhdVxAmVnd1doUlIRGUGV6ZRPfB9BvIwDF10oCOmL742IsvsFIAV4tSMxSfu5e0Px0QeJLgWVOSbtHIvblzqMw==", "dev": true, - "engines": { - "node": ">=0.12" + "dependencies": { + "@rollup/plugin-inject": "^5.0.5", + "node-stdlib-browser": "^1.2.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/davidmyersdev" + }, + "peerDependencies": { + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" } }, - "node_modules/vite-plugin-svgr/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/vite-plugin-static-copy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-1.0.1.tgz", + "integrity": "sha512-3eGL4mdZoPJMDBT68pv/XKIHR4MgVolStIxxv1gIBP4R8TpHn9C9EnaU0hesqlseJ4ycLGUxckFTu/jpuJXQlA==", "dev": true, "dependencies": { - "argparse": "^2.0.1" + "chokidar": "^3.5.3", + "fast-glob": "^3.2.11", + "fs-extra": "^11.1.0", + "picocolors": "^1.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0" + } + }, + "node_modules/vite-plugin-svgr": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", + "integrity": "sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.5", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": "^2.6.0 || 3 || 4 || 5" } }, "node_modules/vite-tsconfig-paths": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.2.3.tgz", - "integrity": "sha512-xVsA2xe6QSlzBujtWF8q2NYexh7PAUYfzJ4C8Axpe/7d2pcERYxuxGgph9F4f0iQO36g5tyGq6eBUYIssdUrVw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.1.tgz", + "integrity": "sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==", "dev": true, "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", - "tsconfck": "^2.1.0" + "tsconfck": "^3.0.1" }, "peerDependencies": { "vite": "*" @@ -14812,54 +14873,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite/node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "peer": true - }, - "node_modules/vite/node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "peer": true, - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -14873,11 +14886,16 @@ "dev": true }, "node_modules/vscode-textmate": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", - "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.0.0.tgz", + "integrity": "sha512-Cl65diFGxz7gpwbav10HqiY/eVYTO1sjQpmRmV991Bj7wAoOAjGQ97PpQcXorDE2Uc4hnGWLY17xme+5t6MlSg==", "dev": true }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/web-namespaces": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", @@ -14889,9 +14907,9 @@ } }, "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", + "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", "engines": { "node": ">= 8" } @@ -14901,6 +14919,12 @@ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -14922,6 +14946,16 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -15013,6 +15047,133 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -15065,9 +15226,10 @@ } }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/yaml": { "version": "1.10.2", diff --git a/webapp/package.json b/webapp/package.json index 7db49c5049..6672354485 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { + "@codemirror/lang-json": "6.0.0", "@dicebear/avatars": "4.10.2", "@dicebear/avatars-identicon-sprites": "4.10.2", "@dicebear/avatars-initials-sprites": "4.10.2", @@ -18,11 +19,12 @@ "@openreplay/tracker": "^3.5.4", "@sentry/browser": "^7.80.0", "@stomp/stompjs": "^6.1.2", + "@tginternal/editor": "^1.13.4", "@tolgee/format-icu": "^5.16.0", "@tolgee/react": "^5.16.0", "@vitejs/plugin-react": "^4.2.1", "clsx": "^1.1.1", - "codemirror": "^5.62.0", + "codemirror": "^6.0.1", "copy-to-clipboard": "^3.3.1", "date-fns": "2.29.2", "diff": "^5.0.0", @@ -35,7 +37,6 @@ "prism-react-renderer": "1.2.1", "prism-svelte": "0.4.7", "react": "^17.0.1", - "react-codemirror2": "^7.3.0", "react-cropper": "2.1.8", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", @@ -44,7 +45,7 @@ "react-google-recaptcha-v3": "1.9.5", "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", - "react-list": "^0.8.16", + "react-list": "^0.8.17", "react-markdown": "^8.0.4", "react-qr-code": "^2.0.7", "react-query": "^3.39.2", @@ -59,6 +60,7 @@ "sockjs-client": "^1.6.1", "use-context-selector": "^1.3.9", "use-debounce": "^10.0.0", + "usehooks-ts": "^2.12.1", "uuid": "9.0.0", "web-vitals": "^2.1.0", "yup": "^0.32.9", @@ -106,14 +108,13 @@ "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", "@tginternal/language-util": "^1.0.6", - "@tolgee/cli": "^1.0.1", - "@types/codemirror": "^5.60.2", + "@tolgee/cli": "^1.4.0", "@types/diff": "^5.0.2", "@types/jest": "^26.0.24", "@types/node": "^18.19.4", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.9", - "@types/react-list": "^0.8.6", + "@types/react-list": "^0.8.11", "@types/react-redux": "^7.1.18", "@types/react-router-dom": "^5.1.8", "@types/yup": "^0.29.13", diff --git a/webapp/src/ThemeProvider.tsx b/webapp/src/ThemeProvider.tsx index b633639d44..c91f6c2117 100644 --- a/webapp/src/ThemeProvider.tsx +++ b/webapp/src/ThemeProvider.tsx @@ -153,6 +153,8 @@ const getTheme = (mode: PaletteMode) => { import: c.import, exampleBanner: c.exampleBanner, tipsBanner: c.tipsBanner, + tokens: c.tokens, + placeholders: c.placeholders, }, mixins: { toolbar: { @@ -255,6 +257,13 @@ const getTheme = (mode: PaletteMode) => { }, }, }, + MuiFormHelperText: { + styleOverrides: { + root: { + marginLeft: 0, + }, + }, + }, }, }); }; diff --git a/webapp/src/colors.tsx b/webapp/src/colors.tsx index dbedec2763..5167400b04 100644 --- a/webapp/src/colors.tsx +++ b/webapp/src/colors.tsx @@ -1,4 +1,5 @@ import { grey } from '@mui/material/colors'; +import { ALL_TOKENS } from './tokens'; const customGrey: Emphasis = { 50: '#f0f2f4', @@ -73,7 +74,6 @@ export type Tile = { export type Cell = { hover: string; selected: string; - inside: string; }; export type Navbar = { @@ -92,6 +92,19 @@ export type TipsBanner = { background: string; }; +export type Placeholder = { + border: string; + background: string; + text: string; +}; + +export type Placeholders = { + variable: Placeholder; + tag: Placeholder; + variant: Placeholder; + inactive: Placeholder; +}; + export type QuickStart = { highlight: string; circleNormal: string; @@ -104,6 +117,18 @@ export type QuickStart = { finishCircle: string; }; +const getTokensByMode = (mode: 'light' | 'dark') => { + const result = {} as Record; + Object.entries(ALL_TOKENS).map(([tokenName, value]) => { + if (typeof value === 'string') { + result[tokenName] = value; + } else { + result[tokenName] = value[mode]; + } + }); + return result; +}; + export const colors = { light: { white: '#fff', @@ -122,10 +147,9 @@ export const colors = { backgroundHover: '#f4f4f6', }, cell: { - hover: '#f7f7f7', - selected: '#EEEFF1', - inside: '#F9F9F9', - }, + hover: '#f9f9f9', + selected: '#F6F6F8', + } satisfies Cell, navbar: { background: '#fff', text: '#2C3C52', @@ -140,7 +164,7 @@ export const colors = { function: '#007300', other: '#002bff', main: '#2C3C52', - }, + } satisfies Editor, billingProgress: { background: '#C4C4C4', low: '#E80000', @@ -188,6 +212,29 @@ export const colors = { tipsBanner: { background: '#FDECF280', }, + tokens: getTokensByMode('light'), + placeholders: { + variable: { + border: '#7AD3C1', + background: '#BEF3E9', + text: '#008371', + }, + tag: { + border: '#F27FA6', + background: '#F9C4D6', + text: '#822343', + }, + variant: { + border: '#BBC2CB', + background: '#F0F2F4', + text: '#4D5B6E', + }, + inactive: { + background: '#e3e7ea', + border: '#e3e7ea', + text: '#808080', + }, + } satisfies Placeholders, }, dark: { white: '#dddddd', @@ -207,8 +254,7 @@ export const colors = { cell: { hover: '#233043', selected: '#243245', - inside: '#283a53', - }, + } satisfies Cell, navbar: { background: '#182230', text: '#dddddd', @@ -239,7 +285,7 @@ export const colors = { function: '#9ac99a', other: '#99aaff', main: '#eeeeee', - }, + } satisfies Editor, billingProgress: { background: '#565656', low: '#ca0000', @@ -286,5 +332,28 @@ export const colors = { tipsBanner: { background: '#29242580', }, + tokens: getTokensByMode('dark'), + placeholders: { + variable: { + border: '#008371', + background: '#008371', + text: '#F0F2F4', + }, + tag: { + border: '#824563', + background: '#824563', + text: '#F0F2F4', + }, + variant: { + border: '#4D5B6E', + background: '#4D5B6E', + text: '#F0F2F4', + }, + inactive: { + border: '#2c3c52', + background: '#2c3c52', + text: '#acacac', + }, + } satisfies Placeholders, }, } as const; diff --git a/webapp/src/component/AutoTranslationIcon.tsx b/webapp/src/component/AutoTranslationIcon.tsx index 4ece729d28..0ea3ffb6ec 100644 --- a/webapp/src/component/AutoTranslationIcon.tsx +++ b/webapp/src/component/AutoTranslationIcon.tsx @@ -4,7 +4,7 @@ import { MachineTranslationIcon, TranslationMemoryIcon, } from 'tg.component/CustomIcons'; -import { useServiceImg } from 'tg.views/projects/translations/TranslationTools/useServiceImg'; +import { useServiceImg } from 'tg.views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg'; import { TranslationFlagIcon } from './TranslationFlagIcon'; const StyledImgWrapper = styled('div')` diff --git a/webapp/src/component/DangerZone/DangerButton.tsx b/webapp/src/component/DangerZone/DangerButton.tsx index 040843eebe..9f01bf1b5e 100644 --- a/webapp/src/component/DangerZone/DangerButton.tsx +++ b/webapp/src/component/DangerZone/DangerButton.tsx @@ -10,5 +10,5 @@ const StyledLoadingButton = styled(LoadingButton)` type Props = ComponentProps; export const DangerButton: React.FC = (props) => { - return ; + return ; }; diff --git a/webapp/src/component/DangerZone/DangerZone.tsx b/webapp/src/component/DangerZone/DangerZone.tsx index 2f6b314d3d..4cfff100f8 100644 --- a/webapp/src/component/DangerZone/DangerZone.tsx +++ b/webapp/src/component/DangerZone/DangerZone.tsx @@ -5,7 +5,7 @@ import { useGlobalContext } from 'tg.globalContext/GlobalContext'; const StyledDangerZone = styled(Box)` display: grid; border-radius: ${({ theme }) => theme.shape.borderRadius}px; - border: 1px solid ${({ theme }) => theme.palette.error.dark}; + border: 1px solid ${({ theme }) => theme.palette.emphasis[300]}; gap: 16px; `; diff --git a/webapp/src/views/projects/translations/LimitedHeightText.tsx b/webapp/src/component/LimitedHeightText.tsx similarity index 98% rename from webapp/src/views/projects/translations/LimitedHeightText.tsx rename to webapp/src/component/LimitedHeightText.tsx index d0869fd747..b83e34d0b0 100644 --- a/webapp/src/views/projects/translations/LimitedHeightText.tsx +++ b/webapp/src/component/LimitedHeightText.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, RefObject } from 'react'; import { Popper, keyframes, styled } from '@mui/material'; -import { useTimer } from './useTimer'; +import { useTimer } from '../fixtures/useTimer'; function getInheritedBackgroundColor(el) { // get default style for current browser @@ -39,7 +39,7 @@ const fadeIn = keyframes` const StyledContainer = styled('div')` position: relative; - display: flex; + display: grid; animation: ${fadeIn} 0.1s ease-in-out; & .text { diff --git a/webapp/src/views/projects/translations/Filters/FiltersComponents.tsx b/webapp/src/component/ListComponents.tsx similarity index 100% rename from webapp/src/views/projects/translations/Filters/FiltersComponents.tsx rename to webapp/src/component/ListComponents.tsx diff --git a/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx b/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx new file mode 100644 index 0000000000..6a7ec8bc1c --- /dev/null +++ b/webapp/src/component/common/form/LoadingCheckboxWithSkeleton.tsx @@ -0,0 +1,105 @@ +import React, { FC, ReactElement } from 'react'; +import { + Box, + Checkbox, + FormControlLabel, + Skeleton, + styled, + Tooltip, +} from '@mui/material'; +import { HelpOutline } from '@mui/icons-material'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; + +export type LoadingCheckboxWithSkeletonProps = { + hint: React.ReactNode; + label: React.ReactNode; + onChange: (e: React.ChangeEvent) => void; + checked?: boolean; + loading: boolean; + labelProps?: Partial>; + labelInnerProps?: Partial>; + customHelpIcon?: ReactElement; +} & React.ComponentProps; + +const StyledLabel = styled('div')` + display: flex; + gap: 5px; + align-items: center; +`; + +const StyledHelpIcon = styled(HelpOutline)` + color: ${({ theme }) => theme.palette.tokens.ICON_PRIMARY}; + font-size: 16px; +`; + +export const LoadingCheckboxWithSkeleton: FC< + LoadingCheckboxWithSkeletonProps +> = ({ + checked, + hint, + label, + labelInnerProps, + labelProps, + loading, + onChange, + customHelpIcon, + ...checkboxProps +}) => { + return ( + +
{label}
+ {hint && ( + + {customHelpIcon || } + + )} + + } + control={ + + ({ + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + zIndex: 10, + opacity: checked == undefined || loading ? 1 : 0, + transition: 'opacity 0.3s', + paddingLeft: '12px', + paddingTop: '12px', + pointerEvents: 'none', + })} + > + {loading ? ( + + ) : ( + + )} + + + + } + {...labelProps} + /> + ); +}; diff --git a/webapp/src/component/common/form/PluralFormCheckbox.tsx b/webapp/src/component/common/form/PluralFormCheckbox.tsx new file mode 100644 index 0000000000..e54551041f --- /dev/null +++ b/webapp/src/component/common/form/PluralFormCheckbox.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Field, useFormikContext } from 'formik'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Box, + Checkbox, + FormControlLabel, + IconButton, + TextField, +} from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { FieldError, FieldLabel } from 'tg.component/FormField'; + +import { LabelHint } from '../LabelHint'; + +type Props = { + pluralParameterName: string; + isPluralName: string; +}; + +export const PluralFormCheckbox = ({ + pluralParameterName, + isPluralName, +}: Props) => { + const { values } = useFormikContext(); + const [expanded, setExpanded] = useState( + values[isPluralName] && values[pluralParameterName] !== 'value' + ); + const isPlural = values[isPluralName]; + const { t } = useTranslate(); + + return ( + + + {({ field }) => ( + + } + label={t('translation_single_label_is_plural')} + sx={{ mr: 0.5 }} + /> + setExpanded((val) => !val)} + disabled={!isPlural} + data-cy="key-plural-checkbox-expand" + > + {expanded ? ( + + ) : ( + + )} + + + )} + + + {expanded && ( + + {({ field, meta }) => ( + + + + + + + + + + )} + + )} + + ); +}; diff --git a/webapp/src/component/editor/Editor.tsx b/webapp/src/component/editor/Editor.tsx index 54859cd226..065e4faf0e 100644 --- a/webapp/src/component/editor/Editor.tsx +++ b/webapp/src/component/editor/Editor.tsx @@ -1,258 +1,223 @@ -import { useMemo, useRef } from 'react'; -import CodeMirror from 'codemirror'; -import { Controlled as CodeMirrorReact, DomEvent } from 'react-codemirror2'; -import { parse } from '@formatjs/icu-messageformat-parser'; -import { GlobalStyles, styled } from '@mui/material'; -import 'codemirror/keymap/sublime'; -import 'codemirror/lib/codemirror.css'; -import 'codemirror/addon/lint/lint'; -import 'codemirror/addon/lint/lint.css'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { minimalSetup } from 'codemirror'; +import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { EditorView, ViewUpdate, keymap, KeyBinding } from '@codemirror/view'; +import { styled, useTheme } from '@mui/material'; +import { + tolgeeSyntax, + PlaceholderPlugin, + TolgeeHighlight, + htmlIsolatesPlugin, + generatePlaceholdersStyle, +} from '@tginternal/editor'; -import icuMode from './icuMode'; -import { useScrollMargins } from 'tg.hooks/useScrollMargins'; import { Direction } from 'tg.fixtures/getLanguageDirection'; -import { useParserErrorTranslation } from 'tg.translationTools/useParserErrorTranslation'; +import { useScrollMargins } from 'tg.hooks/useScrollMargins'; -const StyledWrapper = styled('div')<{ - minheight: string | number; - background: string | undefined; -}>` +const StyledEditor = styled('div')` + font-size: 14px; display: grid; - & .react-codemirror2 { - display: grid; - position: relative; - align-self: stretch; + & .cm-editor { + outline: none; } - & .CodeMirror *::selection { + & .cm-selectionBackground { background: ${({ theme }) => theme.palette.mode === 'dark' - ? theme.palette.emphasis[300] - : theme.palette.emphasis[200]}; + ? theme.palette.emphasis[400] + : theme.palette.emphasis[200]} !important; } - & .CodeMirror { - width: 100%; - min-height: ${({ minheight }) => minheight}px; - height: 100%; - margin-left: -5px; - background: ${({ background, theme }) => - background || theme.palette.background.default}; - - * { - overflow: visible !important; - } - - .CodeMirror-lines { - padding: 0px !important; - } - - .CodeMirror-line { - padding: 0px !important; - color: ${({ theme }) => theme.palette.editor.main} !important; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol' !important; - } - - .CodeMirror-lint-markers { - width: 5px; - } - - .CodeMirror-gutters { - border: 0px; - background: transparent; - } - - .CodeMirror-lint-marker-error { - width: 4px; - background: red; - cursor: default; - position: relative; - top: -1px; - } - - .cm-function { - color: ${({ theme }) => theme.palette.editor.function}; - } - - .cm-string { - color: ${({ theme }) => theme.palette.editor.main}; - } - - .cm-parameter { - color: ${({ theme }) => theme.palette.editor.other}; - } - - .cm-option { - color: ${({ theme }) => theme.palette.editor.other}; - } - - .cm-keyword { - color: ${({ theme }) => theme.palette.editor.other}; - } + & .cm-line { + font-size: 15px; + font-family: ${({ theme }) => theme.typography.fontFamily}; + padding: 0px 1px; + } - .cm-bracket { - color: ${({ theme }) => theme.palette.editor.other}; - } + & .cm-content { + padding: 0px; + } - .cm-def { - color: ${({ theme }) => theme.palette.editor.function}; - } + & .cm-cursor { + border-color: ${({ theme }) => theme.palette.text.primary}; } `; -function linter(text: string, data: any) { - const errors = data.errors; - const translateParserError = data.translateParserError as ReturnType< - typeof useParserErrorTranslation - >; - return errors?.map((error) => { - const location = error.location; - const start = location?.start; - const end = location?.end; - - const startColumn = start?.column - 1; - const endColumn = - start?.column === start?.column ? end?.column : end?.column - 1; - const hint = { - message: translateParserError(error.message?.toLowerCase()), - severity: 'error', - type: 'validation', - from: CodeMirror.Pos(start.line - 1, startColumn), - to: CodeMirror.Pos(end.line - 1, endColumn), - }; - return hint; - }); -} - -CodeMirror.registerHelper('lint', 'icu', linter); - -type Props = { +export type EditorProps = { value: string; onChange?: (val: string) => void; - onSave?: (val: string) => void; - onInsertBase?: (val?: string) => void; - onCancel?: () => void; background?: string; - plaintext?: boolean; + mode: 'placeholders' | 'syntax' | 'plain'; autofocus?: boolean; minHeight?: number | string; onBlur?: () => void; onFocus?: () => void; - shortcuts?: CodeMirror.KeyMap; + shortcuts?: KeyBinding[]; scrollMargins?: Parameters[0]; autoScrollIntoView?: boolean; direction?: Direction; - onKeyDown?: DomEvent; + locale?: string; + editorRef?: React.RefObject; + examplePluralNum?: number; + nested?: boolean; }; -export const Editor: React.FC = ({ +function useRefGroup(value: T): RefObject { + const refObject = useRef(value); + refObject.current = value; + return refObject; +} + +export const Editor: React.FC = ({ value, onChange, - onCancel, - onSave, - onBlur, onFocus, - plaintext, - background, + onBlur, + mode, autofocus, - minHeight = 100, shortcuts, - scrollMargins, - autoScrollIntoView, - direction = 'ltr', - onKeyDown, + minHeight, + direction, + locale, + editorRef, + examplePluralNum, + nested, }) => { - const wrapperRef = useRef(null); - const translateParserError = useParserErrorTranslation(); - - const handleChange = (val: string) => { - onChange?.(val); - }; + const ref = useRef(null); + const editor = useRef(); + const placeholders = useRef(new Compartment()); + const editorTheme = useRef(new Compartment()); + const isolates = useRef(new Compartment()); + const keyBindings = useRef(shortcuts); + const theme = useTheme(); + const callbacksRef = useRefGroup({ + onChange, + onFocus, + onBlur, + }); + const languageCompartment = useRef(new Compartment()); + + const StyledEditorWrapper = useMemo(() => { + return generatePlaceholdersStyle({ + styled, + colors: theme.palette.placeholders, + component: StyledEditor, + }); + }, [theme.palette.placeholders]); + + keyBindings.current = shortcuts; + + useEffect(() => { + const shortcutsUptoDate = shortcuts?.map((value, i) => { + return { + ...value, + run: (val: EditorView) => keyBindings.current?.[i].run?.(val) ?? false, + }; + }); + + const instance = new EditorView({ + parent: ref.current!, + state: EditorState.create({ + doc: value, + extensions: [ + minimalSetup, + Prec.highest(keymap.of(shortcutsUptoDate ?? [])), + EditorView.lineWrapping, + EditorView.updateListener.of((v: ViewUpdate) => { + if (v.focusChanged) { + if (v.view.hasFocus) { + callbacksRef.current?.onFocus?.(); + } else { + callbacksRef.current?.onBlur?.(); + } + } + if (v.docChanged) { + callbacksRef.current?.onChange?.(v.state.doc.toString()); + } + }), + EditorView.contentAttributes.of({ + spellcheck: 'true', + dir: direction || 'ltr', + lang: locale || '', + }), + languageCompartment.current.of([]), + editorTheme.current.of([]), + placeholders.current.of([]), + isolates.current.of([]), + direction === 'rtl' ? htmlIsolatesPlugin : [], + ], + }), + }); + + if (autofocus) { + instance.focus(); + } - const error = useMemo(() => { - try { - parse(value, { captureLocation: true, ignoreTag: true }); - } catch (e) { - return e; + editor.current = instance; + }, [theme.palette.mode]); + + useEffect(() => { + const placholderPlugins = + mode === 'placeholders' + ? [PlaceholderPlugin({ examplePluralNum, nested: Boolean(nested) })] + : []; + const syntaxPlugins = + mode === 'plain' ? [] : [tolgeeSyntax(Boolean(nested))]; + editor.current?.dispatch({ + selection: editor.current.state.selection, + effects: [ + placeholders.current?.reconfigure(placholderPlugins), + languageCompartment.current.reconfigure(syntaxPlugins), + ], + }); + }, [mode, nested, examplePluralNum]); + + useEffect(() => { + const state = editor.current?.state; + const editorValue = state?.doc.toString(); + if (state && editorValue !== value) { + const transaction = state.update({ + changes: { from: 0, to: state.doc.length, insert: value || '' }, + }); + editor.current?.update([transaction]); } }, [value]); - const options: CodeMirror.EditorConfiguration = { - lineNumbers: false, - mode: plaintext ? undefined : 'icu', - autofocus, - lineWrapping: true, - keyMap: 'sublime', - extraKeys: { - Enter: (editor) => onSave?.(editor.getValue()), - Esc: () => onCancel?.(), - Tab: false, - 'Shift-Tab': false, - End: 'goLineRight', - Home: 'goLineLeft', - ...shortcuts, - }, - gutters: ['CodeMirror-lint-markers'], - lint: { - // @ts-ignore - errors: error ? [error] : [], - translateParserError, - }, - inputStyle: 'contenteditable', - spellcheck: !plaintext, - direction: direction, - }; + useEffect(() => { + editor.current?.dispatch({ + effects: editorTheme.current?.reconfigure([ + TolgeeHighlight(theme.palette.editor), + ]), + }); + }, [theme.palette.mode]); + + useEffect(() => { + // set cursor to the end of document + const length = editor.current!.state.doc.length; + editor.current!.dispatch({ selection: { anchor: length } }); + + return () => { + editor.current!.destroy(); + }; + }, []); - const wrapperScrollMargins = useScrollMargins(scrollMargins); + useEffect(() => { + if (editorRef) { + // @ts-ignore + editorRef.current = editor.current; + } + }); return ( - <> - ({ - '.CodeMirror-lint-tooltip': { - background: theme.palette.emphasis[100] + ' !important', - borderRadius: '0px !important', - zIndex: theme.zIndex.tooltip + ' !important', - color: theme.palette.text.primary + ' !important', - }, - '.CodeMirror-lint-message-error': { - backgroundImage: 'unset !important', - paddingLeft: '0px !important', - }, - })} - /> - - { - handleChange(value); - }} - onKeyDown={(...params) => onKeyDown?.(...params)} - onBlur={() => onBlur?.()} - onFocus={(e) => { - onFocus?.(); - if (autoScrollIntoView) { - wrapperRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'nearest', - }); - } - }} - /> - - + ); }; diff --git a/webapp/src/component/editor/EditorJson.tsx b/webapp/src/component/editor/EditorJson.tsx new file mode 100644 index 0000000000..18a1bfee9e --- /dev/null +++ b/webapp/src/component/editor/EditorJson.tsx @@ -0,0 +1,193 @@ +import { RefObject, useEffect, useMemo, useRef } from 'react'; +import { minimalSetup } from 'codemirror'; +import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { EditorView, ViewUpdate, keymap, KeyBinding } from '@codemirror/view'; +import { GlobalStyles, css, styled, useTheme } from '@mui/material'; +import { json, jsonLanguage, jsonParseLinter } from '@codemirror/lang-json'; +import { linter } from '@codemirror/lint'; +import { TolgeeHighlight } from '@tginternal/editor'; + +const StyledEditor = styled('div')` + font-size: 14px; + display: grid; + + & .cm-editor { + outline: none; + } + + & .cm-selectionBackground { + background: ${({ theme }) => + theme.palette.mode === 'dark' + ? theme.palette.emphasis[400] + : theme.palette.emphasis[200]} !important; + } + + & .cm-line { + font-size: 15px; + font-family: ${({ theme }) => theme.typography.fontFamily}; + padding: 0px 1px; + } + + & .cm-content { + padding: 0px; + } + + & .cm-cursor { + border-color: ${({ theme }) => theme.palette.text.primary}; + } +`; + +function useRefGroup(value: T): RefObject { + const refObject = useRef(value); + refObject.current = value; + return refObject; +} + +export type EditorProps = { + value: string; + onChange?: (val: string) => void; + onBlur?: () => void; + onFocus?: () => void; + minHeight?: number | string; + autofocus?: boolean; + shortcuts?: KeyBinding[]; + editorRef?: React.RefObject; +}; + +export const EditorJson: React.FC = ({ + value, + onChange, + onFocus, + onBlur, + minHeight, + autofocus, + shortcuts, + editorRef, +}) => { + const ref = useRef(null); + const editor = useRef(); + const keyBindings = useRef(shortcuts); + const editorTheme = useRef(new Compartment()); + const theme = useTheme(); + const callbacksRef = useRefGroup({ + onChange, + onFocus, + onBlur, + }); + + keyBindings.current = shortcuts; + + useEffect(() => { + const languageCompartment = new Compartment(); + + const shortcutsUptoDate = shortcuts?.map((value, i) => { + return { + ...value, + run: (val: EditorView) => keyBindings.current?.[i].run?.(val) ?? false, + }; + }); + + const instance = new EditorView({ + parent: ref.current!, + state: EditorState.create({ + doc: value, + extensions: [ + minimalSetup, + Prec.highest(keymap.of(shortcutsUptoDate ?? [])), + EditorView.lineWrapping, + EditorView.updateListener.of((v: ViewUpdate) => { + if (v.focusChanged) { + if (v.view.hasFocus) { + callbacksRef.current?.onFocus?.(); + } else { + callbacksRef.current?.onBlur?.(); + } + } + if (v.docChanged) { + callbacksRef.current?.onChange?.(v.state.doc.toString()); + } + }), + editorTheme.current.of([]), + languageCompartment.of([json(), jsonLanguage]), + jsonLanguage, + linter(jsonParseLinter()), + ], + }), + }); + + if (autofocus) { + instance.focus(); + } + + editor.current = instance; + }, [theme.palette.mode]); + + useEffect(() => { + const state = editor.current?.state; + const editorValue = state?.doc.toString(); + if (state && editorValue !== value) { + const transaction = state.update({ + changes: { from: 0, to: state.doc.length, insert: value || '' }, + }); + editor.current?.update([transaction]); + } + }, [value]); + + useEffect(() => { + editor.current?.dispatch({ + effects: editorTheme.current?.reconfigure([ + TolgeeHighlight(theme.palette.editor), + ]), + }); + }, [theme.palette.mode]); + + useEffect(() => { + // set cursor to the end of document + const length = editor.current!.state.doc.length; + editor.current!.dispatch({ selection: { anchor: length } }); + + return () => { + editor.current!.destroy(); + }; + }, []); + + useEffect(() => { + if (editorRef) { + // @ts-ignore + editorRef.current = editor.current; + } + }); + + const globalStyles = useMemo( + () => ( + + ), + [theme] + ); + + return ( + <> + {globalStyles} + + + ); +}; diff --git a/webapp/src/component/editor/EditorWrapper.tsx b/webapp/src/component/editor/EditorWrapper.tsx index 1148103df3..c5d7f248b3 100644 --- a/webapp/src/component/editor/EditorWrapper.tsx +++ b/webapp/src/component/editor/EditorWrapper.tsx @@ -7,6 +7,7 @@ const StyledEditorWrapper = styled('div')` border-radius: 4px; cursor: text; background: ${({ theme }) => theme.palette.background.default}; + padding: 1px; &:hover { border: 1px solid ${({ theme }) => theme.palette.emphasis[900]}; @@ -15,22 +16,19 @@ const StyledEditorWrapper = styled('div')` &:focus-within { border-color: ${({ theme }) => theme.palette.primary.main}; border-width: 2px; + padding: 0px; } & > * { padding: 8px 10px; } - - &:focus-within > * { - padding: 7px 9px; - } `; export const EditorWrapper: React.FC = ({ children, ...props }) => { const handleClick: React.MouseEventHandler = (e) => { - const editor = (e.target as HTMLDivElement).querySelector( - '.CodeMirror-code' - ) as HTMLDivElement | undefined; + const editor = (e.target as HTMLDivElement).querySelector('.cm-content') as + | HTMLDivElement + | undefined; editor?.focus(); }; diff --git a/webapp/src/component/editor/icuMode.ts b/webapp/src/component/editor/icuMode.ts deleted file mode 100644 index e37378b848..0000000000 --- a/webapp/src/component/editor/icuMode.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { ModeFactory, StringStream } from 'codemirror'; - -type Frame = - | { - type: 'argument'; - indentation: number; - formatType?: string; - argPos: number; - } - | { - type: 'escaped'; - } - | { - type: 'text'; - }; - -interface ModeState { - stack: Frame[]; -} - -const mode: ModeFactory = ( - { indentUnit = 2 } = {}, - { apostropheMode = 'DOUBLE_OPTIONAL' } = {} -) => { - function peek(stream: StringStream, offset = 0) { - return stream.string.charAt(stream.pos + offset) || undefined; - } - - function eatEscapedStringStart(stream: StringStream, inPlural: boolean) { - const nextChar = stream.peek(); - if (nextChar === "'") { - if (apostropheMode === 'DOUBLE_OPTIONAL') { - const nextAfterNextChar = peek(stream, 1); - if ( - nextAfterNextChar === "'" || - nextAfterNextChar === '{' || - (inPlural && nextAfterNextChar === '#') - ) { - stream.next(); - return true; - } - } else { - stream.next(); - return true; - } - } - return false; - } - - function eatEscapedStringEnd(stream: StringStream) { - const nextChar = peek(stream, 0); - if (nextChar === "'") { - const nextAfterNextChar = peek(stream, 1); - if (!nextAfterNextChar || nextAfterNextChar !== "'") { - stream.next(); - return true; - } - } - return false; - } - - function pop(stack: Frame[]) { - if (stack.length > 1) { - stack.pop(); - return true; - } - return false; - } - - return { - startState() { - return { - stack: [ - { - type: 'text', - }, - ], - }; - }, - - copyState(state) { - return { - stack: state.stack.map((frame) => Object.assign({}, frame)), - }; - }, - - token(stream, state) { - const current = state.stack[state.stack.length - 1]; - const isInsidePlural = !!state.stack.find( - (frame) => - frame.type === 'argument' && - frame.formatType && - ['selectordinal', 'plural'].includes(frame.formatType) - ); - - if (current.type === 'escaped') { - if (eatEscapedStringEnd(stream)) { - pop(state.stack); - return 'string-2'; - } - - stream.match("''") || stream.next(); - return 'string-2'; - } - - if (current.type === 'text') { - if (eatEscapedStringStart(stream, isInsidePlural)) { - state.stack.push({ type: 'escaped' }); - return 'string-2'; - } - - if (isInsidePlural && stream.eat('#')) { - return 'keyword'; - } - - if (stream.eat('{')) { - state.stack.push({ - type: 'argument', - indentation: stream.indentation() + indentUnit, - argPos: 0, - }); - return 'bracket'; - } - - if (stream.peek() === '}') { - if (pop(state.stack)) { - stream.next(); - return 'bracket'; - } - } - - stream.next(); - return 'string'; - } - - if (current.type === 'argument') { - const inId = current.argPos === 0; - const inFn = current.argPos === 1; - const inFormat = current.argPos === 2; - if (stream.match(/\s*,\s*/)) { - current.argPos += 1; - return null; - } - if (inId && stream.eatWhile(/[a-zA-Z0-9_]/)) { - return 'def'; - } - if ( - inFn && - stream.match(/(selectordinal|plural|select|number|date|time)\b/) - ) { - current.formatType = stream.current(); - return 'function'; - } - if (inFormat && stream.match(/offset\b/)) { - return 'option'; - } - if (inFormat && stream.eat('=')) { - return 'operator'; - } - if ( - inFormat && - current.formatType && - ['selectordinal', 'plural'].includes(current.formatType) && - stream.match(/zero|one|two|few|many/) - ) { - return 'option'; - } - if (inFormat && stream.match('other')) { - return 'option'; - } - if (inFormat && stream.match(/[0-9]+\b/)) { - return 'number'; - } - if (inFormat && stream.eatWhile(/[a-zA-Z0-9_]/)) { - return 'variable'; - } - if (inFormat && stream.eat('{')) { - state.stack.push({ type: 'text' }); - return 'bracket'; - } - if (stream.eat('}')) { - pop(state.stack); - return 'bracket'; - } - } - - if (!stream.eatSpace()) { - stream.next(); - } - - return null; - }, - - blankLine(state) { - const current = state.stack[state.stack.length - 1]; - if (current.type === 'text') { - return 'cm-string'; - } - return undefined; - }, - - indent(state, textAfter) { - const current = state.stack[state.stack.length - 1]; - if (!current || current.type === 'text' || current.type === 'escaped') { - return 0; - } - if (textAfter[0] === '}') { - return current.indentation - indentUnit; - } - return current.indentation; - }, - }; -}; - -export default mode; diff --git a/webapp/src/component/editor/icuParser.ts b/webapp/src/component/editor/icuParser.ts deleted file mode 100644 index d272bb9889..0000000000 --- a/webapp/src/component/editor/icuParser.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - parse, - TYPE, - MessageFormatElement, - PluralOrSelectOption, -} from '@formatjs/icu-messageformat-parser'; - -export type ParameterType = { - name: string; - function: string | null; - options: string[]; -}; - -const flatOptions = (options: PluralOrSelectOption[]) => { - let elements: ParameterType[] = []; - options.forEach((o) => - o.value.forEach((el) => { - elements = [...elements, ...flatTree(el)]; - }) - ); - return elements; -}; - -const flatTree = (root: MessageFormatElement): ParameterType[] => { - switch (root.type) { - case TYPE.select: - return [ - { - name: root.value, - function: TYPE[root.type], - options: Object.keys(root.options), - }, - ...flatOptions(Object.values(root.options)), - ]; - case TYPE.plural: - return [ - { - name: root.value, - function: root.pluralType === 'cardinal' ? 'plural' : 'selectordinal', - options: Object.keys(root.options), - }, - ...flatOptions(Object.values(root.options)), - ]; - case TYPE.date: - case TYPE.time: - case TYPE.number: - case TYPE.argument: - return [ - { - name: root.value, - function: TYPE[root.type], - options: [], - }, - ]; - case TYPE.tag: - return [ - { - name: root.value, - function: TYPE[root.type], - options: [], - }, - ...root.children.flatMap(flatTree), - ]; - default: - return []; - } -}; - -export const getParameters = (text: string) => { - let final: ParameterType[] = []; - const elements = parse(text, { ignoreTag: true }); - for (const element of elements) { - final = [...final, ...flatTree(element)]; - } - return final; -}; diff --git a/webapp/src/component/editor/icuVariants.ts b/webapp/src/component/editor/icuVariants.ts deleted file mode 100644 index 61162067bf..0000000000 --- a/webapp/src/component/editor/icuVariants.ts +++ /dev/null @@ -1,172 +0,0 @@ -import IntlMessageFormat from 'intl-messageformat'; -import { getParameters, ParameterType } from './icuParser'; - -const ENUMERABLE_FUNCTIONS = ['plural', 'select', 'selectordinal']; - -type VariantType = { option: string; value: string }; - -type VariantsSummaryType = { - parameters: ParameterType[]; - variants: VariantType[] | null; - parseError: string | null; -}; - -// Taken from -// https://unicode-org.github.io/cldr-staging/charts/37/supplemental/language_plural_rules.html -const POSSIBLE_MANY = [6, 7, 8, 11, 20, 21, 1000000, 0.5, 0.1, 0.0]; -const POSSIBLE_FEW = [0, 2, 3, 4, 6]; -const POSSIBLE_OTHER = [10, 11, 20, 100, 0.0, 0, 0.1, 2, 3, 4]; - -const getExampleValue = (paramName: string, func: string | null) => { - switch (func) { - case 'number': - return 42; - case 'date': - case 'time': - return new Date(); - case 'tag': - return (content) => `<${paramName}>${content}`; - default: - return `{${paramName}}`; - } -}; - -const findCategoryExample = ( - category: string, - locale: string, - list: number[], - type: Intl.PluralRuleType -) => { - const intl = new Intl.PluralRules(locale, { type }); - return list.find((num) => intl.select(num) === category); -}; - -const getPluralExample = ( - option: string, - locale: string, - type: Intl.PluralRuleType -) => { - if (option[0] === '=') { - return Number(option.replace('=', '')); - } else if (option === 'zero') { - return findCategoryExample('zero', locale, [0], type); - } else if (option === 'one') { - return findCategoryExample('one', locale, [1], type); - } else if (option === 'two') { - return findCategoryExample('two', locale, [2], type); - } else if (option === 'few') { - return findCategoryExample('few', locale, POSSIBLE_FEW, type); - } else if (option === 'many') { - return findCategoryExample('many', locale, POSSIBLE_MANY, type); - } else if (option === 'other') { - return findCategoryExample('other', locale, POSSIBLE_OTHER, type); - } else { - return undefined; - } -}; - -export const icuVariants = ( - text: string, - locale = 'en' -): VariantsSummaryType => { - let variants: VariantType[] | null = null; - let parseError: string | null = null; - const functions: ParameterType[] = []; - const variables: ParameterType[] = []; - - let parameters: ParameterType[] = []; - - try { - parameters = getParameters(text); - - for (const p of parameters) { - if (ENUMERABLE_FUNCTIONS.includes(p.function!) && p.options?.length > 0) { - functions.push(p); - } else { - variables.push(p); - } - } - - const staticVariables = variables.reduce( - (obj, item) => ({ - ...obj, - [item.name]: getExampleValue(item.name, item.function), - }), - {} - ); - - if (functions.length === 0 && parameters.length) { - const formattedValue = new IntlMessageFormat(text, locale, undefined, { - ignoreTag: true, - }).format(staticVariables); - variants = [ - { - value: formattedValue as string, - option: '', - }, - ]; - } else if (functions.length === 1) { - const func = functions[0]; - if (func.function === 'select') { - variants = func.options - .map((option) => { - const formattedValue = new IntlMessageFormat( - text, - locale, - undefined, - { ignoreTag: true } - ).format({ - ...staticVariables, - [func.name]: option, - }); - return { - value: formattedValue as string, - option, - }; - }) - .filter(Boolean) as VariantType[]; - } else if ( - func.function === 'plural' || - func.function === 'selectordinal' - ) { - variants = func.options - .map((option) => { - const example = getPluralExample( - option, - locale, - func.function === 'plural' ? 'cardinal' : 'ordinal' - ); - if (example === undefined) { - return { - value: '-', - option, - }; - } - const formattedValue = new IntlMessageFormat( - text, - locale, - undefined, - { ignoreTag: true } - ).format({ - ...staticVariables, - [func.name]: example, - }); - return { - value: formattedValue as string, - option, - }; - }) - .filter(Boolean) as VariantType[]; - } - } - } catch (e: any) { - variants = null; - parseError = e.message; - } - - return { - parameters, - variants: variants?.length ? variants : null, - parseError, - }; -}; diff --git a/webapp/src/component/layout/BaseView.tsx b/webapp/src/component/layout/BaseView.tsx index f252c61f13..721fa9cd2a 100644 --- a/webapp/src/component/layout/BaseView.tsx +++ b/webapp/src/component/layout/BaseView.tsx @@ -49,6 +49,7 @@ export interface BaseViewProps { 'data-cy'?: string; initialSearch?: string; overflow?: string; + wrapperProps?: React.ComponentProps; } export const BaseView = (props: BaseViewProps) => { @@ -140,7 +141,7 @@ export const BaseView = (props: BaseViewProps) => { )} - + {!props.loading || !hideChildrenOnLoading ? ( ); + case 'FEATURE_VISUAL_EDITOR': + return ( + + } + link="https://tolgee.io/blog/releasing-visual-editor-and-formats-support" + /> + ); + default: assertUnreachable(value); } diff --git a/webapp/src/component/reactList/ReactList.d.ts b/webapp/src/component/reactList/ReactList.d.ts new file mode 100644 index 0000000000..d90de9e5cf --- /dev/null +++ b/webapp/src/component/reactList/ReactList.d.ts @@ -0,0 +1,35 @@ +import { Component, JSX } from 'react'; + +type ItemRenderer = (index: number, key: number | string) => JSX.Element; +type ItemsRenderer = (items: JSX.Element[], ref: string) => JSX.Element; +type ItemSizeEstimator = ( + index: number, + cache: Record +) => number; +type ItemSizeGetter = (index: number) => number; +type ScrollParentGetter = () => JSX.Element; + +interface ReactListProps { + children?: React.ReactNode; + ref?: React.LegacyRef | undefined; + axis?: 'x' | 'y' | undefined; + initialIndex?: number | undefined; + itemRenderer?: ItemRenderer | undefined; + itemSizeEstimator?: ItemSizeEstimator | undefined; + itemSizeGetter?: ItemSizeGetter | undefined; + itemsRenderer?: ItemsRenderer | undefined; + length?: number | undefined; + minSize?: number | undefined; + pageSize?: number | undefined; + scrollParentGetter?: ScrollParentGetter | undefined; + threshold?: number | undefined; + type?: string | undefined; + useStaticSize?: boolean | undefined; + useTranslate3d?: boolean | undefined; +} + +export declare class ReactList extends Component { + scrollTo(index: number): void; + scrollAround(index: number): void; + getVisibleRange(): number[]; +} diff --git a/webapp/src/component/reactList/ReactList.js b/webapp/src/component/reactList/ReactList.js new file mode 100644 index 0000000000..e0d12e4a7e --- /dev/null +++ b/webapp/src/component/reactList/ReactList.js @@ -0,0 +1,554 @@ +/* eslint-disable */ +/** + * source: https://github.com/caseywebdev/react-list/blob/master/react-list.es6 + */ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +const CLIENT_SIZE_KEYS = { x: 'clientWidth', y: 'clientHeight' }; +const CLIENT_START_KEYS = { x: 'clientTop', y: 'clientLeft' }; +const INNER_SIZE_KEYS = { x: 'innerWidth', y: 'innerHeight' }; +const OFFSET_SIZE_KEYS = { x: 'offsetWidth', y: 'offsetHeight' }; +const OFFSET_START_KEYS = { x: 'offsetLeft', y: 'offsetTop' }; +const OVERFLOW_KEYS = { x: 'overflowX', y: 'overflowY' }; +const SCROLL_SIZE_KEYS = { x: 'scrollWidth', y: 'scrollHeight' }; +const SCROLL_START_KEYS = { x: 'scrollLeft', y: 'scrollTop' }; +const SIZE_KEYS = { x: 'width', y: 'height' }; + +/** + * custom thing for Tolgee + * when editor is open, cell expands, so we keep some reserve at the bottom + * so the layout is not overflowing + */ +const SIZE_RESERVE_IF_ITEMS_EXPAND = 300; + +const NOOP = () => {}; + +// If a browser doesn't support the `options` argument to +// add/removeEventListener, we need to check, otherwise we will +// accidentally set `capture` with a truthy value. +const PASSIVE = (() => { + if (typeof window === 'undefined') return false; + let hasSupport = false; + try { + document.createElement('div').addEventListener('test', NOOP, { + get passive() { + hasSupport = true; + return false; + }, + }); + } catch (e) { + // noop + } + return hasSupport; +})() + ? { passive: true } + : false; + +const UNSTABLE_MESSAGE = 'ReactList failed to reach a stable state.'; +const MAX_SYNC_UPDATES = 40; + +const isEqualSubset = (a, b) => { + for (const key in b) if (a[key] !== b[key]) return false; + + return true; +}; + +const defaultScrollParentGetter = (component) => { + const { axis } = component.props; + let el = component.getEl(); + const overflowKey = OVERFLOW_KEYS[axis]; + while ((el = el.parentElement)) { + switch (window.getComputedStyle(el)[overflowKey]) { + case 'auto': + case 'scroll': + case 'overlay': + return el; + } + } + return window; +}; + +const defaultScrollParentViewportSizeGetter = (component) => { + const { axis } = component.props; + const { scrollParent } = component; + return scrollParent === window + ? window[INNER_SIZE_KEYS[axis]] + : scrollParent[CLIENT_SIZE_KEYS[axis]]; +}; + +const constrain = (props, state) => { + const { length, minSize, type } = props; + let { from, size, itemsPerRow } = state; + size = Math.max(size, minSize); + let mod = size % itemsPerRow; + if (mod) size += itemsPerRow - mod; + if (size > length) size = length; + from = + type === 'simple' || !from ? 0 : Math.max(Math.min(from, length - size), 0); + + if ((mod = from % itemsPerRow)) { + from -= mod; + size += mod; + } + + if (from === state.from && size == state.size) return state; + + return { ...state, from, size }; +}; + +export class ReactList extends Component { + static displayName = 'ReactList'; + + static propTypes = { + axis: PropTypes.oneOf(['x', 'y']), + initialIndex: PropTypes.number, + itemRenderer: PropTypes.func, + itemSizeEstimator: PropTypes.func, + itemSizeGetter: PropTypes.func, + itemsRenderer: PropTypes.func, + length: PropTypes.number, + minSize: PropTypes.number, + pageSize: PropTypes.number, + scrollParentGetter: PropTypes.func, + scrollParentViewportSizeGetter: PropTypes.func, + threshold: PropTypes.number, + type: PropTypes.oneOf(['simple', 'variable', 'uniform']), + useStaticSize: PropTypes.bool, + useTranslate3d: PropTypes.bool, + }; + + static defaultProps = { + axis: 'y', + itemRenderer: (index, key) => React.createElement('div', { key }, index), + itemsRenderer: (items, ref) => React.createElement('div', { ref }, items), + length: 0, + minSize: 1, + pageSize: 10, + scrollParentGetter: defaultScrollParentGetter, + scrollParentViewportSizeGetter: defaultScrollParentViewportSizeGetter, + threshold: 100, + type: 'simple', + useStaticSize: false, + useTranslate3d: false, + }; + + static getDerivedStateFromProps(props, state) { + const newState = constrain(props, state); + return newState === state ? null : newState; + } + + constructor(props) { + super(props); + this.state = constrain(props, { + itemsPerRow: 1, + from: props.initialIndex, + size: 0, + }); + this.cache = {}; + this.cachedScrollPosition = null; + this.prevPrevState = {}; + this.unstable = false; + this.updateCounter = 0; + } + + componentDidMount() { + this.updateFrameAndClearCache = this.updateFrameAndClearCache.bind(this); + window.addEventListener('resize', this.updateFrameAndClearCache); + this.updateFrame(this.scrollTo.bind(this, this.props.initialIndex)); + } + + componentDidUpdate(prevProps) { + // Viewport scroll is no longer useful if axis changes + if (this.props.axis !== prevProps.axis) this.clearSizeCache(); + + // If the list has reached an unstable state, prevent an infinite loop. + if (this.unstable) return; + + if (++this.updateCounter > MAX_SYNC_UPDATES) { + this.unstable = true; + return console.error(UNSTABLE_MESSAGE); + } + + if (!this.updateCounterTimeoutId) { + this.updateCounterTimeoutId = setTimeout(() => { + this.updateCounter = 0; + delete this.updateCounterTimeoutId; + }, 0); + } + + this.updateFrame(); + } + + maybeSetState(b, cb) { + if (isEqualSubset(this.state, b)) return cb(); + + this.setState(b, cb); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.updateFrameAndClearCache); + this.scrollParent.removeEventListener( + 'scroll', + this.updateFrameAndClearCache, + PASSIVE + ); + this.scrollParent.removeEventListener('mousewheel', NOOP, PASSIVE); + } + + getOffset(el) { + const { axis } = this.props; + let offset = el[CLIENT_START_KEYS[axis]] || 0; + const offsetKey = OFFSET_START_KEYS[axis]; + do offset += el[offsetKey] || 0; + while ((el = el.offsetParent)); + return offset; + } + + getEl() { + return this.el || this.items; + } + + getScrollPosition() { + // Cache scroll position as this causes a forced synchronous layout. + if (typeof this.cachedScrollPosition === 'number') { + return this.cachedScrollPosition; + } + const { scrollParent } = this; + const { axis } = this.props; + const scrollKey = SCROLL_START_KEYS[axis]; + const actual = + scrollParent === window + ? // Firefox always returns document.body[scrollKey] as 0 and Chrome/Safari + // always return document.documentElement[scrollKey] as 0, so take + // whichever has a value. + document.body[scrollKey] || document.documentElement[scrollKey] + : scrollParent[scrollKey]; + const max = + this.getScrollSize() - this.props.scrollParentViewportSizeGetter(this); + const scroll = Math.max(0, Math.min(actual, max)); + const el = this.getEl(); + this.cachedScrollPosition = + this.getOffset(scrollParent) + scroll - this.getOffset(el); + return this.cachedScrollPosition; + } + + setScroll(offset) { + const { scrollParent } = this; + const { axis } = this.props; + offset += this.getOffset(this.getEl()); + if (scrollParent === window) return window.scrollTo(0, offset); + + offset -= this.getOffset(this.scrollParent); + scrollParent[SCROLL_START_KEYS[axis]] = offset; + } + + getScrollSize() { + const { scrollParent } = this; + const { body, documentElement } = document; + const key = SCROLL_SIZE_KEYS[this.props.axis]; + return scrollParent === window + ? Math.max(body[key], documentElement[key]) + : scrollParent[key]; + } + + hasDeterminateSize() { + const { itemSizeGetter, type } = this.props; + return type === 'uniform' || itemSizeGetter; + } + + getStartAndEnd(threshold = this.props.threshold) { + const scroll = this.getScrollPosition(); + const start = Math.max(0, scroll - threshold); + let end = + scroll + this.props.scrollParentViewportSizeGetter(this) + threshold; + if (this.hasDeterminateSize()) { + end = Math.min(end, this.getSpaceBefore(this.props.length)); + } + return { start, end }; + } + + getItemSizeAndItemsPerRow() { + const { axis, useStaticSize } = this.props; + let { itemSize, itemsPerRow } = this.state; + if (useStaticSize && itemSize && itemsPerRow) { + return { itemSize, itemsPerRow }; + } + + const itemEls = this.items.children; + if (!itemEls.length) return {}; + + const firstEl = itemEls[0]; + + // Firefox has a problem where it will return a *slightly* (less than + // thousandths of a pixel) different size for the same element between + // renders. This can cause an infinite render loop, so only change the + // itemSize when it is significantly different. + const firstElSize = firstEl[OFFSET_SIZE_KEYS[axis]]; + const delta = Math.abs(firstElSize - itemSize); + if (isNaN(delta) || delta >= 1) itemSize = firstElSize; + + if (!itemSize) return {}; + + const startKey = OFFSET_START_KEYS[axis]; + const firstStart = firstEl[startKey]; + itemsPerRow = 1; + for ( + let item = itemEls[itemsPerRow]; + item && item[startKey] === firstStart; + item = itemEls[itemsPerRow] + ) { + ++itemsPerRow; + } + + return { itemSize, itemsPerRow }; + } + + clearSizeCache() { + this.cachedScrollPosition = null; + } + + // Called by 'scroll' and 'resize' events, clears scroll position cache. + updateFrameAndClearCache(cb) { + this.clearSizeCache(); + return this.updateFrame(cb); + } + + updateFrame(cb) { + this.updateScrollParent(); + if (typeof cb != 'function') cb = NOOP; + switch (this.props.type) { + case 'simple': + return this.updateSimpleFrame(cb); + case 'variable': + return this.updateVariableFrame(cb); + case 'uniform': + return this.updateUniformFrame(cb); + } + } + + updateScrollParent() { + const prev = this.scrollParent; + this.scrollParent = this.props.scrollParentGetter(this); + if (prev === this.scrollParent) return; + if (prev) { + prev.removeEventListener('scroll', this.updateFrameAndClearCache); + prev.removeEventListener('mousewheel', NOOP); + } + // If we have a new parent, cached parent dimensions are no longer useful. + this.clearSizeCache(); + this.scrollParent.addEventListener( + 'scroll', + this.updateFrameAndClearCache, + PASSIVE + ); + // You have to attach mousewheel listener to the scrollable element. + // Just an empty listener. After that onscroll events will be fired synchronously. + this.scrollParent.addEventListener('mousewheel', NOOP, PASSIVE); + } + + updateSimpleFrame(cb) { + const { end } = this.getStartAndEnd(); + const itemEls = this.items.children; + let elEnd = 0; + + if (itemEls.length) { + const { axis } = this.props; + const firstItemEl = itemEls[0]; + const lastItemEl = itemEls[itemEls.length - 1]; + elEnd = + this.getOffset(lastItemEl) + + lastItemEl[OFFSET_SIZE_KEYS[axis]] - + this.getOffset(firstItemEl); + } + + if (elEnd > end) return cb(); + + const { pageSize, length } = this.props; + const size = Math.min(this.state.size + pageSize, length); + this.maybeSetState({ size }, cb); + } + + updateVariableFrame(cb) { + if (!this.props.itemSizeGetter) this.cacheSizes(); + + const { start, end } = this.getStartAndEnd(); + const { length, pageSize } = this.props; + let space = 0; + let from = 0; + let size = 0; + const maxFrom = length - 1; + + while (from < maxFrom) { + const itemSize = this.getSizeOfItem(from); + if (itemSize == null || space + itemSize > start) break; + space += itemSize; + ++from; + } + + const maxSize = length - from; + + while (size < maxSize && space < end) { + const itemSize = this.getSizeOfItem(from + size); + if (itemSize == null) { + size = Math.min(size + pageSize, maxSize); + break; + } + space += itemSize; + ++size; + } + + this.maybeSetState( + constrain(this.props, { from, itemsPerRow: 1, size }), + cb + ); + } + + updateUniformFrame(cb) { + const { itemSize, itemsPerRow } = this.getItemSizeAndItemsPerRow(); + + if (!itemSize || !itemsPerRow) return cb(); + + const { start, end } = this.getStartAndEnd(); + + const { from, size } = constrain(this.props, { + from: Math.floor(start / itemSize) * itemsPerRow, + size: (Math.ceil((end - start) / itemSize) + 1) * itemsPerRow, + itemsPerRow, + }); + + return this.maybeSetState({ itemsPerRow, from, itemSize, size }, cb); + } + + getSpaceBefore(index, cache = {}) { + if (cache[index] != null) return cache[index]; + + // Try the static itemSize. + const { itemSize, itemsPerRow } = this.state; + if (itemSize) { + return (cache[index] = Math.floor(index / itemsPerRow) * itemSize); + } + + // Find the closest space to index there is a cached value for. + let from = index; + while (from > 0 && cache[--from] == null); + + // Finally, accumulate sizes of items from - index. + let space = cache[from] || 0; + for (let i = from; i < index; ++i) { + cache[i] = space; + const itemSize = this.getSizeOfItem(i); + if (itemSize == null) break; + space += itemSize; + } + + return (cache[index] = space); + } + + cacheSizes() { + const { cache } = this; + const { from } = this.state; + const itemEls = this.items.children; + const sizeKey = OFFSET_SIZE_KEYS[this.props.axis]; + for (let i = 0, l = itemEls.length; i < l; ++i) { + cache[from + i] = itemEls[i][sizeKey]; + } + } + + getSizeOfItem(index) { + const { cache, items } = this; + const { axis, itemSizeGetter, itemSizeEstimator, type } = this.props; + const { from, itemSize, size } = this.state; + + // Try the static itemSize. + if (itemSize) return itemSize; + + // Try the itemSizeGetter. + if (itemSizeGetter) return itemSizeGetter(index); + + // Try the cache. + if (index in cache) return cache[index]; + + // Try the DOM. + if (type === 'simple' && index >= from && index < from + size && items) { + const itemEl = items.children[index - from]; + if (itemEl) return itemEl[OFFSET_SIZE_KEYS[axis]]; + } + + // Try the itemSizeEstimator. + if (itemSizeEstimator) return itemSizeEstimator(index, cache); + } + + scrollTo(index) { + if (index != null) this.setScroll(this.getSpaceBefore(index)); + } + + scrollAround(index) { + const current = this.getScrollPosition(); + const bottom = this.getSpaceBefore(index); + const top = + bottom - + this.props.scrollParentViewportSizeGetter(this) + + this.getSizeOfItem(index); + const min = Math.min(top, bottom); + const max = Math.max(top, bottom); + if (current <= min) return this.setScroll(min); + if (current > max) return this.setScroll(max); + } + + getVisibleRange() { + const { from, size } = this.state; + const { start, end } = this.getStartAndEnd(0); + const cache = {}; + let first, last; + for (let i = from; i < from + size; ++i) { + const itemStart = this.getSpaceBefore(i, cache); + const itemEnd = itemStart + this.getSizeOfItem(i); + if (first == null && itemEnd > start) first = i; + if (first != null && itemStart < end) last = i; + } + return [first, last]; + } + + renderItems() { + const { itemRenderer, itemsRenderer } = this.props; + const { from, size } = this.state; + const items = []; + for (let i = 0; i < size; ++i) items.push(itemRenderer(from + i, i)); + return itemsRenderer(items, (c) => (this.items = c)); + } + + render() { + const { axis, length, type, useTranslate3d } = this.props; + const { from, itemsPerRow } = this.state; + + const items = this.renderItems(); + if (type === 'simple') return items; + + const style = { position: 'relative' }; + const cache = {}; + const bottom = Math.ceil(length / itemsPerRow) * itemsPerRow; + const size = this.getSpaceBefore(bottom, cache); + if (size) { + style[SIZE_KEYS[axis]] = size + SIZE_RESERVE_IF_ITEMS_EXPAND; + if (axis === 'x') style.overflowX = 'hidden'; + } + const offset = this.getSpaceBefore(from, cache); + const x = axis === 'x' ? offset : 0; + const y = axis === 'y' ? offset : 0; + const transform = useTranslate3d + ? `translate3d(${x}px, ${y}px, 0)` + : `translate(${x}px, ${y}px)`; + const listStyle = { + msTransform: transform, + WebkitTransform: transform, + transform, + }; + return React.createElement( + 'div', + { style: style, ref: (c) => (this.el = c) }, + React.createElement('div', { style: listStyle }, items) + ); + } +} diff --git a/webapp/src/component/searchSelect/SearchSelectMulti.tsx b/webapp/src/component/searchSelect/SearchSelectMulti.tsx index 9436e78d0e..b7937ff82d 100644 --- a/webapp/src/component/searchSelect/SearchSelectMulti.tsx +++ b/webapp/src/component/searchSelect/SearchSelectMulti.tsx @@ -12,7 +12,6 @@ import { Add } from '@mui/icons-material'; import { useTranslate } from '@tolgee/react'; import { SelectItem } from 'tg.component/searchSelect/SearchSelect'; -import { CompactMenuItem } from '../../views/projects/translations/Filters/FiltersComponents'; import { StyledWrapper, StyledHeading, @@ -20,6 +19,7 @@ import { StyledInputContent, StyledInputWrapper, } from './SearchStyled'; +import { CompactMenuItem } from '../ListComponents'; function PopperComponent(props) { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/webapp/src/component/security/LanguagePermissionsMenu.tsx b/webapp/src/component/security/LanguagePermissionsMenu.tsx index 81f3f1b477..b29d738f41 100644 --- a/webapp/src/component/security/LanguagePermissionsMenu.tsx +++ b/webapp/src/component/security/LanguagePermissionsMenu.tsx @@ -5,11 +5,11 @@ import { useTranslate } from '@tolgee/react'; import { LanguagesPermittedList } from 'tg.component/languages/LanguagesPermittedList'; import { SearchSelectMulti } from 'tg.component/searchSelect/SearchSelectMulti'; -import { CompactMenuItem } from 'tg.views/projects/translations/Filters/FiltersComponents'; import { StyledInputContent } from 'tg.component/searchSelect/SearchStyled'; import { CircledLanguageIcon } from 'tg.component/languages/CircledLanguageIcon'; import { LanguageModel } from 'tg.component/PermissionsSettings/types'; import { isAllLanguages } from 'tg.ee/PermissionsAdvanced/hierarchyTools'; +import { CompactMenuItem } from 'tg.component/ListComponents'; const StyledButton = styled(Button)` padding: 0px; diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index b013aa3b1c..e03688dfb4 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -4,6 +4,8 @@ import * as Yup from 'yup'; import { components } from 'tg.service/apiSchema.generated'; import { organizationService } from '../service/OrganizationService'; import { signUpService } from '../service/SignUpService'; +import { checkParamNameIsValid } from '@tginternal/editor'; +import { validateObject } from 'tg.fixtures/validateObject'; type TFunType = TFnType; @@ -373,6 +375,32 @@ export class Validation { static readonly WEBHOOK_FORM = Yup.object().shape({ url: Yup.string().required().max(255), }); + + static readonly NEW_KEY_FORM = (t: TFnType) => + Yup.object().shape({ + name: Yup.string().required(), + pluralParameter: Yup.string() + .required() + .when('isPlural', { + is: true, + then: Yup.string().test( + 'invalid-plural-parameter', + // @tolgee-key validation_invalid_plural_parameter + t('validation_invalid_plural_parameter'), + (value) => checkParamNameIsValid(value ?? '') + ), + }), + }); + + static readonly KEY_SETTINGS_FORM = (t: TFnType) => + Yup.object().shape({ + custom: Yup.string().test( + 'invalid-custom-values', + // @tolgee-key validation_invalid_plural_parameter + t('validation_invalid_custom_values'), + validateObject + ), + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index d4b61e52af..b5fb07d9c3 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -308,6 +308,7 @@ export class LINKS { static PROJECT_MANAGE = Link.ofParent(LINKS.PROJECT, 'manage'); static PROJECT_EDIT = Link.ofParent(LINKS.PROJECT_MANAGE, 'edit'); + static PROJECT_EDIT_ADVANCED = Link.ofParent(LINKS.PROJECT_EDIT, 'advanced'); static PROJECT_LANGUAGES = Link.ofParent(LINKS.PROJECT, 'languages'); diff --git a/webapp/src/custom.d.ts b/webapp/src/custom.d.ts index 3973c61a0e..24d4d93207 100644 --- a/webapp/src/custom.d.ts +++ b/webapp/src/custom.d.ts @@ -10,6 +10,7 @@ import { ExampleBanner, Marker, Navbar, + Placeholders, QuickStart, Tile, TipsBanner, @@ -44,6 +45,8 @@ declare module '@mui/material/styles/createPalette' { import: typeof all.import; exampleBanner: ExampleBanner; tipsBanner: TipsBanner; + tokens: typeof all.tokens; + placeholders: Placeholders; } interface PaletteOptions { @@ -65,6 +68,8 @@ declare module '@mui/material/styles/createPalette' { import: typeof all.import; exampleBanner: ExampleBanner; tipsBanner: TipsBanner; + tokens: typeof all.tokens; + placeholders: Placeholders; } } @@ -79,3 +84,9 @@ declare global { openReplayTracker?: API; } } + +declare module 'react' { + interface HTMLAttributes extends AriaAttributes, DOMAttributes { + webkitdirectory?: boolean; + } +} diff --git a/webapp/src/docLinks.ts b/webapp/src/docLinks.ts new file mode 100644 index 0000000000..78dbc3e191 --- /dev/null +++ b/webapp/src/docLinks.ts @@ -0,0 +1,6 @@ +export const DOC_LINKS = { + importOverridingDescriptions: + 'https://tolgee.io/platform/projects_and_organizations/import#overriding-key-descriptions', + importingPlaceholders: + 'https://tolgee.io/platform/projects_and_organizations/import#importing-placeholders', +}; diff --git a/webapp/src/fixtures/FileUploadFixtures.ts b/webapp/src/fixtures/FileUploadFixtures.ts index 4444cf95cf..f4c4112a2f 100644 --- a/webapp/src/fixtures/FileUploadFixtures.ts +++ b/webapp/src/fixtures/FileUploadFixtures.ts @@ -9,3 +9,106 @@ export class FileUploadFixtures { return result; }; } + +const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +export async function getFilesAsync(dataTransfer: DataTransfer) { + const files: FilesType = []; + + const items = [...dataTransfer.items].map((item) => ({ + // looks like the dataTransfer is suspect to bugs in browsers, so we need to extract the data from it, + // or it's pruned when iterating over it + kind: item.kind, + webkitEntry: + typeof item.webkitGetAsEntry === 'function' && !isSafari + ? item.webkitGetAsEntry() + : null, + getAsFile: () => item.getAsFile(), + })); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + // Safari doesn't support the File System Access API and hangs + + if (item.webkitEntry != null) { + const entry = item.webkitEntry; + if (!entry) { + continue; + } + const entryContent = await readEntryContentAsync(entry); + files.push(...entryContent); + continue; + } + + const file = item.getAsFile(); + if (file) { + files.push({ file, name: file.name }); + } + } + } + + return files; +} + +export type FilesType = { + file: File; + name: string; +}[]; + +// Returns a promise with all the files of the directory hierarchy +function readEntryContentAsync(entry: FileSystemEntry) { + return new Promise((resolve, reject) => { + let reading = 0; + const contents: FilesType = []; + + readEntry([], entry); + + function readEntry(path: string[], entry: FileSystemEntry) { + if (isFile(entry)) { + reading++; + entry.file((file) => { + reading--; + const name = (path.join('/') + '/' + file.name).replace(/^\//, ''); + contents.push({ name, file }); + + if (reading === 0) { + resolve(contents); + } + }); + } else if (isDirectory(entry)) { + readReaderContent([...path, entry.name], entry.createReader()); + } + } + + function readReaderContent( + path: string[], + reader: FileSystemDirectoryReader + ) { + reading++; + + reader.readEntries(function (entries) { + reading--; + for (const entry of entries) { + readEntry(path, entry); + } + + if (reading === 0) { + resolve(contents); + } + }); + } + }); +} + +// for TypeScript typing (type guard function) +// https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards +function isDirectory( + entry: FileSystemEntry +): entry is FileSystemDirectoryEntry { + return entry.isDirectory; +} + +function isFile(entry: FileSystemEntry): entry is FileSystemFileEntry { + return entry.isFile; +} diff --git a/webapp/src/fixtures/isElementInput.ts b/webapp/src/fixtures/isElementInput.ts new file mode 100644 index 0000000000..495955c596 --- /dev/null +++ b/webapp/src/fixtures/isElementInput.ts @@ -0,0 +1,12 @@ +export const isElementInput = (activeElement: Element) => { + if ( + activeElement.tagName === 'TEXTAREA' || + activeElement.classList.contains('MuiInputBase-root') || + (activeElement.tagName === 'INPUT' && + // @ts-ignore + !['checkbox', 'radio', 'submit', 'reset'].includes(activeElement.type)) + ) { + return true; + } + return false; +}; diff --git a/webapp/src/views/projects/translations/useTimer.ts b/webapp/src/fixtures/useTimer.ts similarity index 100% rename from webapp/src/views/projects/translations/useTimer.ts rename to webapp/src/fixtures/useTimer.ts diff --git a/webapp/src/fixtures/validateObject.ts b/webapp/src/fixtures/validateObject.ts new file mode 100644 index 0000000000..03b467c62c --- /dev/null +++ b/webapp/src/fixtures/validateObject.ts @@ -0,0 +1,13 @@ +export const validateObject = (value: any) => { + if (!value) { + return false; + } + try { + const parsed = JSON.parse(value); + return Boolean( + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ); + } catch (e) { + return false; + } +}; diff --git a/webapp/src/globalContext/GlobalContext.tsx b/webapp/src/globalContext/GlobalContext.tsx index ab84dcdad9..5012121732 100644 --- a/webapp/src/globalContext/GlobalContext.tsx +++ b/webapp/src/globalContext/GlobalContext.tsx @@ -12,12 +12,15 @@ import { useQuickStartGuide } from './useQuickStartGuide'; type UsageModel = components['schemas']['PublicUsageModel']; +export const TOP_BAR_HEIGHT = 52; + export const [GlobalProvider, useGlobalActions, useGlobalContext] = createProvider(() => { const [clientConnected, setClientConnected] = useState(); const [client, setClient] = useState>(); const initialData = useInitialDataService(); const [topBannerHeight, setTopBannerHeight] = useState(0); + const [topSubBannerHeight, setTopSubBannerHeight] = useState(0); const [rightPanelWidth, setRightPanelWidth] = useState(0); const [topBarHidden, setTopBarHidden] = useState(false); const [quickStartState, quickStartActions] = @@ -68,6 +71,7 @@ export const [GlobalProvider, useGlobalActions, useGlobalContext] = return organizationUsage.incrementSpendingLimitErrors(); }, setTopBannerHeight, + setTopSubBannerHeight, dismissTopBanner: () => { return initialData.dismissAnnouncement(); }, @@ -85,8 +89,9 @@ export const [GlobalProvider, useGlobalActions, useGlobalContext] = client, clientConnected, topBannerHeight, + topSubBannerHeight, rightPanelWidth, - topBarHeight: topBarHidden ? 0 : 52, + topBarHeight: topBarHidden ? 0 : TOP_BAR_HEIGHT, quickStartGuide: quickStartState, }; diff --git a/webapp/src/globalContext/useQuickStartGuide.tsx b/webapp/src/globalContext/useQuickStartGuide.tsx index 1cf2a72c36..390216e33c 100644 --- a/webapp/src/globalContext/useQuickStartGuide.tsx +++ b/webapp/src/globalContext/useQuickStartGuide.tsx @@ -14,7 +14,9 @@ export const useQuickStartGuide = ( ) => { const [active, setActive] = useState([]); const [activeStep, setActiveStep] = useState(); - const floating = useMediaQuery(`@media (max-width: ${1200}px)`); + const [floatingForced, setFloatingForced] = useState(false); + const floating = + useMediaQuery(`@media (max-width: ${1200}px)`) || floatingForced; const match = useRouteMatch(LINKS.PROJECT.template); const projectIdParam = match?.params[PARAMS.PROJECT_ID]; const projectId = isNaN(projectIdParam) ? undefined : projectIdParam; @@ -99,6 +101,7 @@ export const useQuickStartGuide = ( quickStartVisited, quickStartCompleteStep, quickStartSkipTips: skipTips, + quickStartForceFloating: setFloatingForced, }; return [state, actions] as const; diff --git a/webapp/src/i18n/cs.json b/webapp/src/i18n/cs.json index 32500be49c..4cf51cc194 100644 --- a/webapp/src/i18n/cs.json +++ b/webapp/src/i18n/cs.json @@ -168,6 +168,7 @@ "announcement_feature_mt_formality" : "Strojové překlady nyní podporují formálnost", "announcement_general_link_text" : "Zobrazit více", "announcement_new_pricing" : "Tolgee představuje nové ceny pro vlastní hostování", + "announcement_visual_editor_and_formats_support" : "Nový vizuální editor a podpora formátů!", "api-key-delete-button" : "Odstranit", "api-key-deleted-message" : "API klíč byl odstraněn", "api-key-delete-token-confirmation-message" : "Skutečně chcete odstranit tento API klíč?", @@ -274,7 +275,7 @@ "billing_plan_credits_included" : "Měsíční MT kredity", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/měs", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/měsíc za dalšího uživatele", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} za dalších 1000 MT kreditů", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} za dalších 1000 MT kreditů", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/měs za dalších 1000 řetězců", "billing_plan_resubscribe" : "Obnovit předplatné", "billing_plan_strings_included" : "Zahrnuté řetězce", @@ -433,6 +434,8 @@ "export_translations_nested_hint" : "Překlady budou vnořeny na základě oddělovače \".\" v klíčích", "export_translations_nested_label" : "Vnořená struktura", "export_translations_states_label" : "Stavy", + "export_translations_support_arrays_hint" : "Pokud je povoleno, klíče jako \"item[0]\" budou převedeny na pole ve výsledném souboru.", + "export_translations_support_arrays_label" : "Podpora polí", "export_translations_title" : "Exportovat překlady", "feature-explanation-check-license-action" : "Zobrazit licenci", "feature-explanation-license-not-active" : "Tolgee licence není aktivní", @@ -532,6 +535,7 @@ "import_file_issues_title" : "Problémy souboru", "import_files_uploaded" : "Soubory byly nahrány", "import_file_supported_formats" : "podporované formáty jsou .json, .xliff, .po", + "import_file_supported_formats_title" : "Podporované formáty", "import_language_select" : "Jazyk", "import_max_file_count_message" : "Příliš mnoho souborů", "import_namespace_name_header" : "Namespace", @@ -660,6 +664,7 @@ "login_tolgee_documentation_link" : "Další informace najdete v dokumentaci", "login_tolgee_website_link" : "Podívejte se na naše skvělé funkce na webu Tolgee", "machine_translation_buy_more_credit" : "Koupit více kreditů", + "machine_translation_empty" : "Prázdné", "machine_translation_new_keys_title" : "Automatický překlad nových klíčů", "machine_translation_title" : "Strojový překlad", "managed-account-field-hint" : "Toto spravuje vaše organizace.", @@ -695,15 +700,6 @@ "no_screenshots_yet" : "Zatím nebyly přidány žádné snímky obrazovky.", "operation_not_permitted" : "Nemáte dostatečná oprávnění pro tuto operaci.", "operation_not_permitted_error" : "Nemáte dostatečná oprávnění pro tuto operaci.", - "order_translation_dialog_cancel" : "Zrušit", - "order_translation_dialog_consent" : "Beru na vědomí, že moje kontaktní údaje (e-mail) budou sdíleny s dodavatelem třetí strany a povoluji dodavateli, aby mě kontaktoval.", - "order_translation_dialog_note_label" : "Komentář", - "order_translation_dialog_note_placeholder" : "Poskytněte prosím další informace o vašem projektu", - "order_translation_dialog_projects_label" : "Vyberte projekt(y)", - "order_translation_dialog_send_invitation" : "Chci pozvat {provider} jako člena do vybraných projektů.", - "order_translation_dialog_submit" : "Odeslat", - "order_translation_dialog_subtitle" : "Vyberte projekty, pro které chcete získat cenovou nabídku. Tolgee sdílí počet slov a jazyky s poskytovatelem. Poskytovatel vám poté zašle odhad ceny e-mailem.", - "order_translation_dialog_title" : "Získat cenovou nabídku od {provider}", "organization_already_subscribed" : "Organizace již využívá nějaké předplatné.", "organization-billing-self-hosted-active-subscriptions" : "Aktivní předplatné", "organization-billing-self-hosted-cancel-subscription-button" : "Zrušit", @@ -942,7 +938,6 @@ "project_menu_integrate" : "Integrace", "project_menu_languages" : "Jazyky", "project_menu_members" : "Uživatelé", - "project_menu_order_translation" : "Objednat překladovou službu", "project_menu_projects" : "Projekty", "project_menu_project_settings" : "Nastavení projektu", "project_menu_translations" : "Překlady", @@ -957,8 +952,6 @@ "project_mt_dialog_service_suggested_hint" : "Služby strojového překladu zobrazené v návrzích v překladovém panelu", "project_mt_dialog_settings_inherited_message" : "Zděděno z výchozího nastavení", "project_mt_dialog_title" : "Nastavení strojového překladu", - "project_order_translation_subtitle" : "Objednat překlad od profesionálních překladatelů", - "project_order_translation_title" : "Objednat překladovou službu", "project_permission_information_text_base_permission_after" : "To znamená, že každý uživatel má alespoň toto oprávnění. Nelze mu proto nastavit oprávnění nižší.", "project_permission_information_text_base_permission_before" : "Tento projekt je vlastněn organizací, která má základní oprávnění nastaveno na:", "project_permissions_revoke_user_access_message" : "Skutečně odstranit uživatele {userName} z projektu?", @@ -1081,11 +1074,12 @@ "translation_failed" : "Překlad selhal", "translation_grid_key_text" : "Klíč", "Translation grid - Successfully deleted!" : "Překlady odstraněny!", - "translation_provider_get_quote" : "Získat odhad nákladů", + "translation_memory_empty" : "Prázdné", "translations_auto_translated_provider" : "Přeloženo automaticky pomocí {provider}", "translations_auto_translated_tm" : "Přeloženo automaticky pomocí překladové paměti", "translations_cell_cancel" : "Zrušit", "translations_cell_change_state" : "Změnit stav", + "translations_cell_close" : "Zavřít", "translations_cell_edit" : "Editovat", "translations_cell_insert_base" : "Vložit základní text", "translations_cell_outdated" : "Zastaralé (základní překlad se změnil)", @@ -1181,7 +1175,6 @@ "translations_shortcuts_in_editor_title" : "V editoru", "translations_shortcuts_in_list_title" : "V seznamu", "translations_shortcuts_move" : "Pohyb", - "translations_shortcuts_title" : "Klávesové zkratky", "translations_tag_create" : "Přidat \"{tag}\"", "translations_tag_label" : "tag", "translations_tags_no_results" : "Nic nenalezeno", diff --git a/webapp/src/i18n/da.json b/webapp/src/i18n/da.json index 80280c63a4..0763d0642c 100644 --- a/webapp/src/i18n/da.json +++ b/webapp/src/i18n/da.json @@ -125,6 +125,7 @@ "administration_ee_plan_create" : "Opret plan", "administration_ee_plan_created_success" : "Plan oprettet", "administration_ee_plan_deleted_success" : "Plan slettet", + "administration_ee_plan_delete_message" : "Er du sikker på, at du vil slette denne prisplan?", "administration_ee_plan_edit" : "Rediger plan", "administration_ee_plan_edit_button" : "Rediger", "administration_ee_plan_field_free" : "Gratis", @@ -167,6 +168,7 @@ "announcement_feature_mt_formality" : "Maskinoversættelser understøtter nu tone formalitet", "announcement_general_link_text" : "Vis mere", "announcement_new_pricing" : "Tolgee introducerer ny prissætning for selvhostede instanser", + "announcement_visual_editor_and_formats_support" : "Ny visuel editor og understøttelse af formater er udgivet!", "api-key-delete-button" : "Slet", "api-key-deleted-message" : "API-nøgle slettet", "api-key-delete-token-confirmation-message" : "Er du sikker på, at du vil slette denne API-nøgle?", @@ -273,7 +275,7 @@ "billing_plan_credits_included" : "Månedlige MT-kreditter", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/md", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/md pr. ekstra plads", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} pr. ekstra 1000 MT-kreditter", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} pr. ekstra 1000 MT-kreditter", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/md pr. ekstra 1000 strenge", "billing_plan_resubscribe" : "Gendan abonnement", "billing_plan_strings_included" : "Inkluderede strenge", @@ -432,6 +434,8 @@ "export_translations_nested_hint" : "Oversættelser vil blive indlejret baseret på \".\" separator i nøgler", "export_translations_nested_label" : "Indlejret struktur", "export_translations_states_label" : "Statusser", + "export_translations_support_arrays_hint" : "Når aktiveret, vil nøgler som \"item[0]\" blive konverteret til JSON-arrays.", + "export_translations_support_arrays_label" : "Understøt arrays", "export_translations_title" : "Eksporter oversættelser", "feature-explanation-check-license-action" : "Vis licens", "feature-explanation-license-not-active" : "Tolgee licensen er ikke aktiv", @@ -531,6 +535,7 @@ "import_file_issues_title" : "Problemer med filer", "import_files_uploaded" : "Filer uploaded", "import_file_supported_formats" : "understøttede formater er .json, .xliff, .po", + "import_file_supported_formats_title" : "Understøttede formater", "import_language_select" : "Sprog", "import_max_file_count_message" : "For mange filer", "import_namespace_name_header" : "Navnerum", @@ -659,6 +664,7 @@ "login_tolgee_documentation_link" : "Lær mere i dokumentationen", "login_tolgee_website_link" : "Tjek vores seje funktioner på Tolgee.io", "machine_translation_buy_more_credit" : "Køb flere kreditter", + "machine_translation_empty" : "Tom", "machine_translation_new_keys_title" : "Automatisk oversættelse af nye nøgler", "machine_translation_title" : "Maskinoversættelse", "managed-account-field-hint" : "Dette administreres af din organisation.", @@ -694,15 +700,6 @@ "no_screenshots_yet" : "Ingen skærmbilleder er blevet tilføjet endnu.", "operation_not_permitted" : "Dine tilladelser er ikke tilstrækkelige til denne handling.", "operation_not_permitted_error" : "Dine tilladelser er ikke tilstrækkelige til denne handling.", - "order_translation_dialog_cancel" : "Annuller", - "order_translation_dialog_consent" : "Jeg forstår, at mine kontaktoplysninger (e-mail) vil blive delt med en tredjepartsleverandør, og jeg giver hermed leverandøren tilladelse til at kontakte mig.", - "order_translation_dialog_note_label" : "Kommentar", - "order_translation_dialog_note_placeholder" : "Giv venligst yderligere oplysninger om dit projekt", - "order_translation_dialog_projects_label" : "Vælg projekt(er)", - "order_translation_dialog_send_invitation" : "Jeg vil gerne invitere {provider} som medlem til de valgte projekter.", - "order_translation_dialog_submit" : "Indsend", - "order_translation_dialog_subtitle" : "Vælg de projekter, du gerne vil have et tilbud på. Tolgee deler antal ord og sprog med leverandøren, hvorefter leverandøren vender tilbage til dig med et estimat per e-mail.", - "order_translation_dialog_title" : "Modtag et tilbud på oversættelseservice fra {provider}", "organization_already_subscribed" : "Organisationen har allerede en aktiv plan.", "organization-billing-self-hosted-active-subscriptions" : "Aktive abonnementer", "organization-billing-self-hosted-cancel-subscription-button" : "Annuller", @@ -941,7 +938,6 @@ "project_menu_integrate" : "Integrer", "project_menu_languages" : "Sprog", "project_menu_members" : "Medlemmer", - "project_menu_order_translation" : "Bestil oversættelsesservice", "project_menu_projects" : "Projekter", "project_menu_project_settings" : "Projektindstillinger", "project_menu_translations" : "Oversættelser", @@ -956,8 +952,6 @@ "project_mt_dialog_service_suggested_hint" : "Tjenester vist i forslagene på oversættelsespanelet", "project_mt_dialog_settings_inherited_message" : "Nedarvet fra standardindstillingerne", "project_mt_dialog_title" : "Maskinoversættelsesindstillinger", - "project_order_translation_subtitle" : "Bestil oversættelse fra professionelle oversættere", - "project_order_translation_title" : "Bestil oversættelsesservice", "project_permission_information_text_base_permission_after" : "Dette betyder, at hver bruger har mindst denne tilladelse. Du kan ikke sætte den lavere end det.", "project_permission_information_text_base_permission_before" : "Dette projekt er en del af en organisation, hvor grundlæggende tilladelser er indstillet til:", "project_permissions_revoke_user_access_message" : "Vil du virkelig tilbagekalde adgangen for brugeren {userName}?", @@ -1080,11 +1074,12 @@ "translation_failed" : "Oversættelse mislykkedes", "translation_grid_key_text" : "Nøgle", "Translation grid - Successfully deleted!" : "Oversættelser slettet!", - "translation_provider_get_quote" : "Få et prisoverslag", + "translation_memory_empty" : "Tom", "translations_auto_translated_provider" : "Oversat automatisk med {provider}", "translations_auto_translated_tm" : "Oversat automatisk med oversættelseshukommelse", "translations_cell_cancel" : "Annuller", "translations_cell_change_state" : "Ændre status", + "translations_cell_close" : "Luk", "translations_cell_edit" : "Rediger", "translations_cell_insert_base" : "Indsæt basis-streng", "translations_cell_outdated" : "Forældet (basis-streng er ændret)", @@ -1180,7 +1175,6 @@ "translations_shortcuts_in_editor_title" : "I editor", "translations_shortcuts_in_list_title" : "I liste", "translations_shortcuts_move" : "Flyt", - "translations_shortcuts_title" : "Genvejstaster", "translations_tag_create" : "Tilføj \"{tag}\"", "translations_tag_label" : "Tag", "translations_tags_no_results" : "Intet fundet", diff --git a/webapp/src/i18n/de.json b/webapp/src/i18n/de.json index c3dd01ed4d..c5ad0532d7 100644 --- a/webapp/src/i18n/de.json +++ b/webapp/src/i18n/de.json @@ -29,14 +29,14 @@ "active-plan-license-key-button" : "Lizenzschlüssel anzeigen", "active-plan-license-key-caption" : "Wenden Sie diesen Lizenzschlüssel in Ihrer lokalen Tolgee-Instanz an", "active-plan-subscribed-at-tooltip" : "Abonniert am", - "activity_batch_operation_auto_translate" : "Automatisch übersetzt {TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}}", - "activity_batch_operation_clear_translations" : "Gelöscht {TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} im Batch", + "activity_batch_operation_auto_translate" : "{TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} automatisch übersetzt ", + "activity_batch_operation_clear_translations" : "{TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} in der Stapelverarbeitung gelöscht", "activity_batch_operation_copy_translations" : "{TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} in der Stapelverarbeitung kopiert", "activity_batch_operation_machine_translate" : "{TranslationCount, plural, one {# Zeichenfolge} other {# Zeichenfolgen}} in der Stapelverarbeitung maschinell übersetzt", - "activity_batch_operation_pre_translate_by_tm" : "Vorübersetzte {TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} in Stapelverarbeitung durch TM", - "activity_batch_operation_set_keys_namespace" : "Namensraum für {KeyCount, plural, one {# Schlüssel} other {# Schlüssel}} im Stapel festlegen", + "activity_batch_operation_pre_translate_by_tm" : "{TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} in Stapelverarbeitung durch TM vorübersetzt", + "activity_batch_operation_set_keys_namespace" : "Namensraum für {KeyCount, plural, one {# Schlüssel} other {# Schlüssel}} im Stapel festgelegt", "activity_batch_operation_set_translation_state" : "Übersetzungszustand für {TranslationCount, plural, one {# Übersetzung} other {# Übersetzungen}} im Batch gesetzt", - "activity_batch_operation_tag_keys" : "Tag hinzugefügt für {KeyMetaCount, plural, one {# Schlüssel} other {# Schlüssel}} im Batch", + "activity_batch_operation_tag_keys" : "Tag für {KeyMetaCount, plural, one {# Schlüssel} other {# Schlüssel}} im Batch hinzugefügt", "activity_batch_operation_untag_keys" : "Tag von {KeyMetaCount, plural, one {# Schlüssel} other {# Schlüsseln}} in der Stapelverarbeitung entfernt", "activity_complex_edit" : "Schlüssel bearbeitet", "activity_create_key" : "Schlüssel erstellt", @@ -125,6 +125,7 @@ "administration_ee_plan_create" : "Plan erstellen", "administration_ee_plan_created_success" : "Plan erstellt", "administration_ee_plan_deleted_success" : "Plan gelöscht", + "administration_ee_plan_delete_message" : "Möchten Sie diesen Preisplan wirklich löschen?", "administration_ee_plan_edit" : "Plan bearbeiten", "administration_ee_plan_edit_button" : "Bearbeiten", "administration_ee_plan_field_free" : "Kostenlos", @@ -273,7 +274,7 @@ "billing_plan_credits_included" : "Monatliche MT-Guthaben", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/Monat", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/Monat pro zusätzlichem Sitzplatz", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} pro zusätzlichen 1000 MT-Guthaben", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} pro zusätzlichen 1000 MT-Guthaben", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/Monat pro zusätzlichen 1000 Zeichen", "billing_plan_resubscribe" : "Abonnement wiederaufnehmen", "billing_plan_strings_included" : "Enthaltene Zeichenfolgen", @@ -332,7 +333,7 @@ "content_delivery_create_success" : "Inhaltsauslieferung erfolgreich erstellt!", "content_delivery_create_title" : "Inhaltsbereitstellung hinzufügen", "content_delivery_delete_success" : "Inhaltsauslieferung erfolgreich gelöscht!", - "content_delivery_deployment_auto" : "Auto", + "content_delivery_deployment_auto" : "Automatisch", "content_delivery_deployment_manual" : "Handbuch", "content_delivery_description" : "Laden Sie Ihre Lokalisierungsdateien in den Inhalts-Speicher hoch, damit Sie sie direkt aus Ihrer Anwendung laden können. Die Veröffentlichungsaktion kann bis zu 15 Minuten dauern (sowohl automatisch als auch manuell), da der Standard-Speicher zwischengespeichert wird.", "content_delivery_form_cancel" : "Abbrechen", @@ -480,7 +481,22 @@ "guide_keys" : "Schlüssel manuell hinzufügen oder Import verwenden", "guide_keys_add" : "Schlüssel hinzufügen\n", "guide_keys_import" : "Importieren", + "guide_languages" : "Sprachen und maschinelle Übersetzung einrichten", + "guide_languages_set_up" : "Einrichten", + "guide_links_docs_platform" : "Dokumentation", + "guide_links_skip" : "Einrichtung Überspringen", + "guide_members" : "Mitglieder in Ihr Team einladen", + "guide_members_invite" : "Einladen", + "guide_new_project" : "Starten Sie Ihr erstes Projekt", + "guide_new_project_create" : "Projekt erstellen", + "guide_new_project_demo" : "Demo ausprobieren", + "guide_production" : "Vorbereitung für die Produktion", "guide_production_content_delivery" : "Inhaltsbereitstellung", + "guide_title" : "Schnellstart", + "guide_try_demo_project" : "Demo-Projekt ausprobieren", + "guide_use" : "Integrieren Sie Tolgee in Ihre Anwendung", + "guide_use_export" : "Exportieren", + "guide_use_integrate" : "Integrieren", "hard_mode_confirmation_rewrite_text" : "Text wiederholen: {text}", "import-add-files-operation" : "Dateien werden hochgeladen...", "import_add_new_language_dialog_title" : "Neue Sprache hinzufügen", @@ -516,6 +532,7 @@ "import_file_issues_title" : "Datei-Probleme", "import_files_uploaded" : "Dateien hochgeladen", "import_file_supported_formats" : "unterstützte Formate sind .json, .xliff, .po", + "import_file_supported_formats_title" : "Unterstützte Formate", "import_language_select" : "Sprache", "import_max_file_count_message" : "Zu viele Dateien", "import_namespace_name_header" : "Namespace", @@ -550,8 +567,10 @@ "integrate-initial-api-key-description-value" : "Mein Integrations-API-Schlüssel", "integrate_step_integrate" : "Integrieren", "integrate_step_select_api_key" : "API-Schlüssel auswählen", + "invalid_connection_string" : "Verbindungsinformationen fehlerhaft", "invalid_language_tag" : "Dieses Sprachkennzeichen entspricht nicht der Norm BCP 47. Nutzen Sie bitte einen gültigen Tag.", "invalid_otp_code" : "Ungültiger 2FA-Code", + "invalid_plural_form" : "ICU-Pluralform ist ungültig", "invalid_recaptcha_token" : "Du bist ein Roboter!", "invitation_code_accepted" : "Einladung erfolgreich angenommen", "invitation_code_does_not_exist_or_expired" : "Diese Einladung existiert nicht oder ist abgelaufen", @@ -589,6 +608,7 @@ "key_edit_modal_switch_advanced" : "Erweitert", "key_edit_modal_switch_context" : "Kontext", "key_edit_modal_switch_general" : "Allgemein", + "key_edit_modeal_switch_custom_properties" : "Benutzerdefinierte Werte", "key_exists" : "Schlüssel vorhanden", "key_exists_in_namespace" : "Schlüssel existiert bereits im Namespace", "key_is_blank" : "Schlüssel ist leer", @@ -720,7 +740,7 @@ "organization_users_projects_description" : "Der Benutzer hat direkten Zugriff auf diese Projekte:", "organization_users_projects_title" : "Benutzerprojekte", "organization_users_remove_user" : "Entfernen", - "organization_your_address_to_access_organization" : "Dies wird die URL Ihrer Organisation sein: {address}", + "organization_your_address_to_access_organization" : "Dies ist die URL Ihrer Organisation: {address}", "out_of_credits" : "Kein Guthaben mehr für maschinelle Übersetzung", "paid-feature-banner-title" : "Ihr Plan enthält diese Funktion nicht.", "parser_duplicate_plural_argument_selector" : "Optionen duplizieren", @@ -819,6 +839,7 @@ "permissions_item_translations_edit" : "bearbeiten", "permissions_item_translations_state" : "Status ändern", "permissions_item_translations_view" : "Siehe", + "permissions_item_webhooks_manage" : "Webhooks verwalten", "permissions_reset_message" : "Berechtigungen zurückgesetzt", "permissions_set_message" : "Berechtigungen eingestellt", "permissions_settings_scopes" : "Berechtigungen:", @@ -852,6 +873,8 @@ "project_create_base_language_label" : "Basissprache", "project_created_message" : "Projekt erstellt!", "project_create_languages_title" : "Sprachen", + "project_create_tolgee_placeholders_hint" : "Bei den universellen Platzhaltern von Tolgee handelt es sich um eine Weiterentwicklung der ICU-Platzhalter, welche beim Export automatisch in Zielformate umgewandelt werden", + "project_create_use_tolgee_placeholders_label" : "Verwendung universeller ICU-Platzhalter von Tolgee", "project_creation_add_at_least_one_language" : "Mindestens eine Sprache hinzufügen", "project_dashboard_base_words_count" : "Basiswörter", "project_dashboard_chart_daily_activity_count_tooltip" : "Tägliche Aktivität", @@ -922,22 +945,27 @@ "project_mt_dialog_service_not_supported" : "Anbieter unterstützt diese Sprache nicht", "project_mt_dialog_service_primary" : "Primär", "project_mt_dialog_service_suggested" : "Vorgeschlagen", - "project_mt_dialog_service_suggested_hint" : "In den Übersetzungstools angezeigte Dienste", + "project_mt_dialog_service_suggested_hint" : "Dienste, die in den Vorschlägen im Übersetzungspanel angezeigt werden", "project_mt_dialog_settings_inherited_message" : "Von den Standard-Einstellungen geerbt", "project_mt_dialog_title" : "Maschinelle Übersetzungseinstellungen", "project_permission_information_text_base_permission_after" : "Das bedeutet, dass jeder Benutzer mindestens diese Berechtigung hat. Niedriger können Sie es nicht einstellen.", "project_permission_information_text_base_permission_before" : "Dieses Projekt ist Teil einer Organisation, für die die Basisberechtigungen festgelegt sind:", "project_permissions_revoke_user_access_message" : "Wollen Sie wirklich den Zugang für den Benutzer {userName} sperren?", + "projects_add_button" : "Projekt hinzufügen", "projects_empty" : "Keine Projekte", "projects_empty_action" : "Projekt hinzufügen", "project_settings_base_language" : "Basissprache", "project_settings_button" : "Einstellungen", "project_settings_danger_zone_title" : "Gefahrenzone", "project_settings_description_label" : "Beschreibung (Markdown)", + "project_settings_development_title" : "Entwicklung", + "project_settings_menu_advanced" : "Erweitert", + "project_settings_menu_general" : "Allgemein", "project_settings_name_label" : "Name", "project_settings_title" : "Projekt-Einstellungen", "project_settings_tolgee_placeholders_hint" : "Tolgee-Platzhalter sind eine Teilmenge der ICU-Platzhalter, die beim Export automatisch in Zielformate umgewandelt werden", "project_settings_use_tolgee_placeholders_label" : "Tolgee Universal ICU-Platzhalter verwenden", + "projects_search_placeholder" : "Projekt suchen...", "projects_title" : "Projekte", "project_successfully_edited_message" : "Projekteinstellungen erfolgreich gespeichert.", "project_successfully_left" : "Projekt links.", @@ -946,7 +974,15 @@ "project_transfer_rewrite_project_name_to_confirm_message" : "Um diese Aktion zu bestätigen, geben Sie den Projektnamen ein.", "quick_start_highlight_ok" : "Ok", "quick_start_highlight_skip" : "Tipps überspringen", + "quick_start_item_add_language_hint" : "Projektsprachen hinzufügen oder bearbeiten", "quick_start_item_automatic_translation_hint" : "Übersetze Schlüssel automatisch, wenn sie erstellt oder geändert werden!", + "quick_start_item_export_form_hint" : "Exportieren Sie statische Dateien und verwenden Sie sie direkt in Ihrer Anwendung", + "quick_start_item_integrate_form_hint" : "Verbinden Sie Ihre App direkt mit Tolgee und profitieren Sie von der einzigartigen Funktion zur kontextbezogenen Bearbeitung und vielem mehr!", + "quick_start_item_invitations_hint" : "Neue Benutzer einladen oder bestehende Einladungen verwalten", + "quick_start_item_machine_translation_hint" : "Einrichten maschineller Übersetzungsdienste oder automatischen Übersetzungen", + "quick_start_item_members_hint" : "Verwalten Sie bestehende Mitglieder und ihre Berechtigungen", + "quick_start_item_pick_import_file_hint" : "Laden Sie Ihre vorhandenen Übersetzungsdateien auf die Plattform hoch", + "quick_start_translation_namespace_input_hint" : "Namespaces ermöglicht es, Übersetzungen in separate Übersetzungsdateien zu gliedern. Sie sind nützlich für größere Projekte.", "really_leave_organization_confirmation_message" : "Wollen Sie die Organisation wirklich verlassen?", "really_remove_user_confirmation" : "Möchten Sie wirklich den Benutzer {userName} aus der Organisation entfernen?", "really_want_to_change_base_permission_confirmation" : "Möchten Sie wirklich die Basisberechtigungen für diese Organisation ändern?", @@ -1020,6 +1056,7 @@ "this_will_delete_project_forever" : "Mit dieser Option wird dieses Projekt für immer gelöscht.", "this_will_transfer_project" : "Damit wird das Projekt auf einen anderen Eigentümer übertragen.", "token-regenerate-message" : "Das aktuelle Token wird ersetzt. Sie werden es nicht mehr benutzen können.", + "tools_panel_hint" : "Hinweis", "tranfer_project_dialog_warning" : "Damit wird das Projekt auf einen anderen Eigentümer übertragen.", "transfer_option_organization" : "Organisation", "transfer_project_apply_button" : "Übertragung", @@ -1036,6 +1073,8 @@ "translations_auto_translated_provider" : "Automatisch übersetzt mit {provider}", "translations_auto_translated_tm" : "Automatisch übersetzt mit Translation Memory", "translations_cell_cancel" : "Abbrechen", + "translations_cell_change_state" : "Status ändern", + "translations_cell_close" : "Schließen", "translations_cell_edit" : "Bearbeiten", "translations_cell_insert_base" : "Basis-Text einfügen", "translations_cell_outdated" : "Veraltet (Basisübersetzung hat sich geändert)", @@ -1049,12 +1088,14 @@ "translations_comments_delete_confirmation" : "Willst du diesen Kommentar wirklich löschen?", "translations_comments_load_more" : "Mehr laden", "translations_comments_needs_resolution" : "Nicht aufgelöst", + "translations_comments_previous_comments" : "Vorherige Kommentare", "translations_comments_resolve" : "Auflösen", "translations_comments_resolved" : "Gelöst", "translations_delete_selected" : "Ausgewählte löschen", "translations_discard_button_confirm" : "Verwerfen", "translations_discard_unsaved_message" : "Möchten Sie Ihre nicht gespeicherten Änderungen verwerfen?", "translations_discard_unsaved_title" : "Änderungen verwerfen?", + "translations_editor_switch_to_placeholders" : "Platzhalter anzeigen", "translations_editor_switch_to_raw" : "Platzhalter ausblenden", "translations_filter_placeholder" : "Filtern...", "translations_filters_heading_clear" : "Filter löschen", @@ -1070,22 +1111,30 @@ "translations_filters_with_screenshots" : "Mit Screenshots", "translations-history-differences-toggle" : "Unterschiede", "translations_history_load_more" : "Mehr laden", + "translations_history_previous_items" : "Vorherige Elemente", "translation_single_create_title" : "Neuen Schlüssel erstellen", "translation_single_delete_success" : "Schlüssel gelöscht!", "translation_single_delete_text" : "Wollen Sie wirklich den Schlüssel mit seinen Übersetzungen löschen?", "translation_single_delete_title" : "Schlüssel löschen", "translation_single_description_hint" : "Beschreiben Sie den Übersetzungskontext. Wenn Sie den Tolgee-Übersetzer verwenden, wird die Beschreibung zur Verbesserung der KI-Übersetzung verwendet.", "translation_single_label_delete" : "Schlüssel entfernen", + "translation_single_label_description" : "Beschreibung", + "translation_single_label_is_plural" : "Pluralformen verwenden", "translation_single_label_key" : "Schlüssel", "translation_single_label_namespace" : "Namespace", + "translation_single_label_plural_hint" : "Ändern Sie den Variablennamen, der für die Pluralform verwendet wird", + "translation_single_label_plural_variable" : "Variablenname", "translation_single_label_screenshots" : "Screenshots", "translation_single_label_tags" : "Tags", + "translation_single_namespace_hint" : "Verwenden Sie Namespaces für große Projekte, bei denen Sie Schlüssel in mehrere Dateien aufteilen möchten", "translation_single_no_permission_create" : "Sie haben keine ausreichenden Berechtigungen für das Hinzufügen eines neuen Schlüssels", "translation_single_tag_placeholder" : "Tag hinzufügen...", "translation_single_translations_title" : "Übersetzungen", "translations_key_created" : "Schlüssel erstellt", "translations_key_delete_confirmation_text" : "Sind Sie sicher, dass Sie {count, plural, one {1 markierten Schlüssel seine Übersetzungen} other {alle # markierten Schlüssel und ihre Übersetzungen}} löschen wollen?", + "translations_key_edit_custom_properties_description" : "Benutzerdefinierte Attribute, die für einige Formate spezifisch sind", "translations_key_edit_label" : "Schlüssel", + "translations_key_edit_label_description" : "Beschreibung", "translations_key_edit_label_description_hint" : "Beschreiben Sie den Kontext der Übersetzung. Wenn Sie den Tolgee-Übersetzer verwenden, wird die Beschreibung verwendet, um die Übersetzung zu verbessern", "translations_key_edit_label_namespace" : "Namespace", "translations_key_edit_label_namespace_hint" : "Verwenden Sie Namespaces für große Projekte, bei denen Sie Schlüssel in mehrere Dateien aufteilen möchten", @@ -1118,6 +1167,8 @@ "translations_screenshots_tooltip" : "Screenshots", "translations_select_all" : "Alle auswählen", "translations_selected_count" : "Ausgewählt {count, number} / {total, number}", + "translations_shortcuts_in_editor_title" : "Im Editor", + "translations_shortcuts_in_list_title" : "In der Liste", "translations_shortcuts_move" : "Navigieren", "translations_tag_create" : "\"{tag}\" hinzufügen", "translations_tag_label" : "Tag", @@ -1132,6 +1183,9 @@ "translations_unsaved_changes_confirmation_title" : "Nicht gespeicherte Änderungen", "translations_view_title" : "Übersetzungen", "translation_tools_base_empty" : "Die Basisübersetzung ist leer", + "translation_tools_comments" : "Kommentare", + "translation_tools_history" : "Verlauf", + "translation_tools_keyboard_shortcuts" : "Tastenkürzel", "translation_tools_limit_exceeded_message" : "Ausgabelimit für MT-Guthaben überschritten, kontaktieren Sie den Support billing@tolgee.io", "translation_tools_machine_translation" : "Maschinelle Übersetzung", "translation_tools_no_credits_billing_link" : "Nicht genug MT Guthaben, Sie können es aufstocken in der Abrechnungs-Sektion", @@ -1171,6 +1225,8 @@ "validation_cannot_contain_coma" : "Darf kein Komma enthalten", "validation_email_is_not_valid" : "Das E-Mail Format ist nicht gültig", "validation_email_not_unique" : "Nutzer mit dieser E-Mail Adresse existiert bereits", + "validation_invalid_custom_values" : "Inhalt muss ein gültiges JSON-Objekt sein", + "validation_invalid_plural_parameter" : "Wert ist kein gültiger ICU-Parameter-Variable", "Validation - required field" : "Dieses Feld ist ein Pflichtfeld", "validation_slug_not_unique" : "Adresse ist nicht eindeutig", "webhook_create_success" : "Webhook erfolgreich erstellt!", diff --git a/webapp/src/i18n/en.json b/webapp/src/i18n/en.json index 71b369f4a4..80c16513a3 100644 --- a/webapp/src/i18n/en.json +++ b/webapp/src/i18n/en.json @@ -168,6 +168,7 @@ "announcement_feature_mt_formality" : "Machine translations now supports formality", "announcement_general_link_text" : "Show more", "announcement_new_pricing" : "Tolgee is introducing new pricing for self-hosted instances", + "announcement_visual_editor_and_formats_support" : "New visual editor and formats support released!", "api-key-delete-button" : "Delete", "api-key-deleted-message" : "API key deleted", "api-key-delete-token-confirmation-message" : "Do you really want to delete this API key?", @@ -274,7 +275,7 @@ "billing_plan_credits_included" : "Monthly MT credits", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/mo", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/mo per extra seat", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} per extra 1000 MT credits", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} per extra 1000 MT credits", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/mo per extra 1000 strings", "billing_plan_resubscribe" : "Restore subscription", "billing_plan_strings_included" : "Included strings", @@ -433,6 +434,8 @@ "export_translations_nested_hint" : "Translations will be nested based on \".\" separator in keys", "export_translations_nested_label" : "Nested structure", "export_translations_states_label" : "States", + "export_translations_support_arrays_hint" : "When enabled keys like \"item[0]\" will be converted to json arrays.", + "export_translations_support_arrays_label" : "Support arrays", "export_translations_title" : "Export translations", "feature-explanation-check-license-action" : "Show license", "feature-explanation-license-not-active" : "Tolgee license is not active", @@ -532,6 +535,7 @@ "import_file_issues_title" : "File issues", "import_files_uploaded" : "Files uploaded", "import_file_supported_formats" : "supported formats are .json, .xliff, .po", + "import_file_supported_formats_title" : "Supported formats", "import_language_select" : "Language", "import_max_file_count_message" : "Too many files", "import_namespace_name_header" : "Namespace", @@ -660,6 +664,7 @@ "login_tolgee_documentation_link" : "Learn more in the documentation", "login_tolgee_website_link" : "Check our cool features on Tolgee website", "machine_translation_buy_more_credit" : "Buy more credits", + "machine_translation_empty" : "Empty", "machine_translation_new_keys_title" : "Automatic translation of new keys", "machine_translation_title" : "Machine translation", "managed-account-field-hint" : "This is managed by your organization.", @@ -695,15 +700,6 @@ "no_screenshots_yet" : "No screenshots have been added yet.", "operation_not_permitted" : "Your permissions are not sufficient for this operation.", "operation_not_permitted_error" : "Your permissions are not sufficient for this operation.", - "order_translation_dialog_cancel" : "Cancel", - "order_translation_dialog_consent" : "I understand that my contact details (email) will be shared with a third-party vendor and I authorize the vendor to contact me.", - "order_translation_dialog_note_label" : "Comment", - "order_translation_dialog_note_placeholder" : "Please provide additional context about your project", - "order_translation_dialog_projects_label" : "Select project(s)", - "order_translation_dialog_send_invitation" : "I would like to Invite {provider} as a member to the selected projects.", - "order_translation_dialog_submit" : "Submit", - "order_translation_dialog_subtitle" : "Select the projects you would like to get the quote for. Tolgee will share the word count and languages with the vendor. The vendor will then get back to you with an estimate via email.", - "order_translation_dialog_title" : "Get quote from {provider}", "organization_already_subscribed" : "Organization is already subscribed to some plan.", "organization-billing-self-hosted-active-subscriptions" : "Active subscriptions", "organization-billing-self-hosted-cancel-subscription-button" : "Cancel", @@ -942,7 +938,6 @@ "project_menu_integrate" : "Integrate", "project_menu_languages" : "Languages", "project_menu_members" : "Members", - "project_menu_order_translation" : "Order translation service", "project_menu_projects" : "Projects", "project_menu_project_settings" : "Project settings", "project_menu_translations" : "Translations", @@ -957,8 +952,6 @@ "project_mt_dialog_service_suggested_hint" : "Services shown in the suggestions in the translation panel", "project_mt_dialog_settings_inherited_message" : "Inherited from default settings", "project_mt_dialog_title" : "Machine translation settings", - "project_order_translation_subtitle" : "Order translation from professional translators", - "project_order_translation_title" : "Order translation service", "project_permission_information_text_base_permission_after" : "This means that every user has at least this permission. You cannot set it lower than that.", "project_permission_information_text_base_permission_before" : "This project is part of organization which has base permissions set to:", "project_permissions_revoke_user_access_message" : "Do you really want to revoke access for user {userName}?", @@ -1081,11 +1074,12 @@ "translation_failed" : "Translation failed", "translation_grid_key_text" : "Key", "Translation grid - Successfully deleted!" : "Translations deleted!", - "translation_provider_get_quote" : "Get cost estimate", + "translation_memory_empty" : "Empty", "translations_auto_translated_provider" : "Translated automatically with {provider}", "translations_auto_translated_tm" : "Translated automatically with translation memory", "translations_cell_cancel" : "Cancel", "translations_cell_change_state" : "Change state", + "translations_cell_close" : "Close", "translations_cell_edit" : "Edit", "translations_cell_insert_base" : "Insert base text", "translations_cell_outdated" : "Outdated (base translation has changed)", @@ -1181,7 +1175,6 @@ "translations_shortcuts_in_editor_title" : "In editor", "translations_shortcuts_in_list_title" : "In list", "translations_shortcuts_move" : "Move", - "translations_shortcuts_title" : "Keyboard shortcuts", "translations_tag_create" : "Add \"{tag}\"", "translations_tag_label" : "tag", "translations_tags_no_results" : "Nothing found", diff --git a/webapp/src/i18n/es.json b/webapp/src/i18n/es.json index 0529fc20d8..a915011bd0 100644 --- a/webapp/src/i18n/es.json +++ b/webapp/src/i18n/es.json @@ -64,8 +64,10 @@ "activity_translation_history_modify" : "Traducción modificada", "add_screenshots_message" : "Añade una soltándola o haciendo clic en el signo más.", "administration-access-message" : "Estás accediendo a esta página como administrador del servidor", + "administration_cloud_plan_create" : "Crear plan", "administration-debugging-customer-account-message" : "Estás depurando la cuenta de usuario.", "administration_delete_user_button" : "Eliminar", + "administration_ee_plan_delete_message" : "¿Realmente quieres eliminar este plan de precios?", "administration-exit-debug-customer-account" : "Salir de la depuración", "administration_organization_projects" : "Proyectos", "administration_organizations" : "Organizaciones", diff --git a/webapp/src/i18n/fr.json b/webapp/src/i18n/fr.json index 2edf744637..1f98b52c01 100644 --- a/webapp/src/i18n/fr.json +++ b/webapp/src/i18n/fr.json @@ -125,6 +125,7 @@ "administration_ee_plan_create" : "Créer un plan", "administration_ee_plan_created_success" : "Plan créé", "administration_ee_plan_deleted_success" : "Plan supprimé", + "administration_ee_plan_delete_message" : "Voulez-vous vraiment supprimer ce plan de tarification ?", "administration_ee_plan_edit" : "Modifier le plan", "administration_ee_plan_edit_button" : "Modifier", "administration_ee_plan_field_free" : "Gratuit", @@ -273,7 +274,7 @@ "billing_plan_credits_included" : "Mensuels crédits TA", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/mois", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/mois par siège supplémentaire", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} par 1000 crédits TA supplémentaires", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} par 1000 crédits TA supplémentaires", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/mois par 1000 chaînes supplémentaires", "billing_plan_resubscribe" : "Renouveler l'abonnement", "billing_plan_strings_included" : "Chaînes incluses", @@ -692,8 +693,6 @@ "no_screenshots_yet" : "Aucune capture d'écran n'a encore été ajoutée.", "operation_not_permitted" : "Vos permissions ne sont pas suffisantes pour faire cette opération. ", "operation_not_permitted_error" : "Vos permissions ne sont pas suffisantes pour faire cette opération. ", - "order_translation_dialog_cancel" : "Annuler", - "order_translation_dialog_note_label" : "Commentaire", "organization_already_subscribed" : "L'organisation est déjà abonnée à un plan.", "organization-billing-self-hosted-active-subscriptions" : "Abonnements actifs", "organization-billing-self-hosted-cancel-subscription-button" : "Annuler", diff --git a/webapp/src/i18n/nl.json b/webapp/src/i18n/nl.json index 8a743fb4ae..cce63a8f88 100644 --- a/webapp/src/i18n/nl.json +++ b/webapp/src/i18n/nl.json @@ -20,7 +20,7 @@ "account-security-mfa-status-enabled" : "Tweestapsverificatie is ingeschakeld.", "account-security-mfa-view-recovery" : "Bekijk herstelcodes", "account-security-set-password" : "Wachtwoord instellen", - "account-security-set-password-instructions-sent" : "We hebben je een e-mail gestuurd met de instructies om te volgen.", + "account-security-set-password-instructions-sent" : "We hebben u een e-mail gestuurd met de instructies om te volgen.", "account-security-set-password-third-party-info" : "Uw account maakt momenteel gebruik van een inlogprocedure van een derde partij. Om uw accountbeveiligingsinstellingen te beheren, moet u eerst een wachtwoord instellen. Zodra er een wachtwoord is ingesteld, kunt u inloggen via zowel het nieuw ingestelde wachtwoord als het derde partij account dat u momenteel gebruikt.", "active-plan-current-period" : "Huidige periode: {start, date} - {end, date}", "active-plan-estimated-costs-description" : "Deze waarde kan veranderen afhankelijk van uw maandelijkse gebruik. U wordt echter dit bedrag in rekening gebracht wanneer uw gebruik constant blijft voor de rest van deze factureringsperiode.", @@ -29,11 +29,11 @@ "active-plan-license-key-button" : "Toon licentiesleutel", "active-plan-license-key-caption" : "Pas deze licentiesleutel toe in uw lokale Tolgee-instantie toe", "active-plan-subscribed-at-tooltip" : "Geabonneerd op", - "activity_batch_operation_auto_translate" : "Automatisch vertaald {TranslationCount, plural, one {# vertaling} other {# vertalingen}}", - "activity_batch_operation_clear_translations" : "Verwijderde {TranslationCount, plural, one {# vertaling} other {# vertalingen}} in batch", - "activity_batch_operation_copy_translations" : "Gekopieerde {TranslationCount, plural, one {# vertaling} other {# vertalingen}} in batch", - "activity_batch_operation_machine_translate" : "Machine vertaald {TranslationCount, plural, one {# string} other {# strings}} in batch", - "activity_batch_operation_pre_translate_by_tm" : "Vooraf vertaald {TranslationCount, plural, one {# vertaling} other {# vertalingen}} door TM in batch", + "activity_batch_operation_auto_translate" : "{TranslationCount, plural, one {# vertaling} other {# vertalingen}} automatisch vertaald", + "activity_batch_operation_clear_translations" : "{TranslationCount, plural, one {# vertaling} other {# vertalingen}} in batch verwijderd", + "activity_batch_operation_copy_translations" : " {TranslationCount, plural, one {# vertaling} other {# vertalingen}} in batch gekopieërd", + "activity_batch_operation_machine_translate" : " {TranslationCount, plural, one {# string} other {# strings}} in batch Machine vertaald", + "activity_batch_operation_pre_translate_by_tm" : "{TranslationCount, plural, one {# vertaling} other {# vertalingen}} vooraf vertaald door TM in batch", "activity_batch_operation_set_keys_namespace" : "Stel de namespace in voor {KeyCount, plural, one {# sleutel} other {# sleutels}} in batch", "activity_batch_operation_set_translation_state" : "Stel de status in voor {TranslationCount, plural, one {# vertaling} other {# vertalingen}} in batch", "activity_batch_operation_tag_keys" : "Tag toegevoegd aan {KeyMetaCount, plural, one {# sleutel} other {# sleutels}} in batch", @@ -62,22 +62,22 @@ "activity_entity_params.source_language_id" : "brontaal", "activity_entity_params.state" : "staat", "activity_entity_params.tags" : "Tags", - "activity_entity_params.target_language_ids" : "doeltaal/talen", + "activity_entity_params.target_language_ids" : "doeltalen", "activity_entity_project" : "Project", "activity_entity_project.language" : "basistaal", "activity_entity_project.name" : "naam", - "activity_entity_screenshot" : "Schermopname", + "activity_entity_screenshot" : "Schermafbeelding", "activity_entity_translation" : "Vertaling", "activity_entity_translation_comment" : "Reactie", "activity_entity_translation.flag_emoji" : "vlag", "activity_entity_translation.name" : "naam", - "activity_entity_translation.tag" : "label", - "activity_import" : "{KeyCount, plural, \n one {Geïmporteerde sleutel}\n other {Geïmporteerde # sleutels}\n} ({TranslationCount, plural, \n one {1 vertaling} \n other {# vertalingen}\n})", - "activity_key_delete" : "Verwijderde {KeyCount, plural, one {1 sleutel} other {# sleutels}}", + "activity_entity_translation.tag" : "tag", + "activity_import" : "{KeyCount, plural, \n one {Geïmporteerde sleutel}\n other {# geïmporteerde sleutels}\n} ({TranslationCount, plural, \n one {1 vertaling} \n other {# vertalingen}\n})", + "activity_key_delete" : "{KeyCount, plural, one {1 sleutel} other {# sleutels}} verwijderd", "activity_key_name_edit" : "Bewerkte sleutelnaam", "activity_key_tags_edit" : "Sleuteltags bijgewerkt", - "activity_screenshot_add" : "Schermopname toegevoegd", - "activity_screenshot_delete" : "Schermopname verwijderd", + "activity_screenshot_add" : "Schermafbeelding toegevoegd", + "activity_screenshot_delete" : "Schermafbeelding verwijderd", "activity_set_outdated_flag" : "Bijgewerkte status", "activity_set_translation" : "Bijgewerkte vertaling", "activity_set_translation_state" : "Stel vertaalstatus in", @@ -100,7 +100,7 @@ "administration_cloud_plan_field_free" : "Gratis", "administration_cloud_plan_field_included_mt_credits" : "MT-credits", "administration_cloud_plan_field_included_translations" : "Vertalingen", - "administration_cloud_plan_field_included_translation_slots" : "Vertaalsleuven", + "administration_cloud_plan_field_included_translation_slots" : "Vertaalsloten", "administration_cloud_plan_field_name" : "Naam", "administration_cloud_plan_field_price_monthly" : "Maandelijks", "administration_cloud_plan_field_price_per_thousand_mt_credits" : "Per 1000 MT-credits", @@ -125,15 +125,15 @@ "administration_ee_plan_create" : "Plan aanmaken", "administration_ee_plan_created_success" : "Plan aangemaakt", "administration_ee_plan_deleted_success" : "Plan verwijderd", - "administration_ee_plan_delete_message" : "Wil je dit prijsplan echt verwijderen?", + "administration_ee_plan_delete_message" : "Wilt u dit prijsplan echt verwijderen?", "administration_ee_plan_edit" : "Plan bewerken", "administration_ee_plan_edit_button" : "Bewerken", "administration_ee_plan_field_free" : "Gratis", "administration_ee_plan_field_included_mt_credits" : "Inbegrepen MT-credits", - "administration_ee_plan_field_included_seats" : "Stoelen", + "administration_ee_plan_field_included_seats" : "Gebruikers", "administration_ee_plan_field_name" : "Naam", "administration_ee_plan_field_price_monthly" : "Maandelijks", - "administration_ee_plan_field_price_per_seat" : "Per stoel", + "administration_ee_plan_field_price_per_seat" : "Per gebruiker", "administration_ee_plan_field_price_per_thousand_mt_credits" : "Per 1000 MT-credits", "administration_ee_plan_field_price_yearly" : "Jaarlijks", "administration_ee_plan_field_public" : "Openbaar", @@ -170,7 +170,7 @@ "announcement_new_pricing" : "Tolgee introduceert nieuwe prijzen voor zelf-gehoste instanties", "api-key-delete-button" : "Verwijderen", "api-key-deleted-message" : "API-sleutel verwijderd", - "api-key-delete-token-confirmation-message" : "Wil je deze API-sleutel echt verwijderen?", + "api-key-delete-token-confirmation-message" : "Wilt u deze API-sleutel echt verwijderen?", "api-key-description-placeholder" : "Mijn API-sleutel", "api-key-form-description" : "Beschrijving", "api-key-list-item-delete" : "Verwijderen", @@ -180,8 +180,8 @@ "api-key-list-item-regenerate" : "Opnieuw genereren", "api-key-list-never-used" : "Nooit gebruikt", "api-key_never_expires" : "Verloopt nooit", - "api-key-new-token-message" : "API-sleutel aangemaakt. Zorg ervoor dat je nu je persoonlijke toegangstoken kopieert. Je zult het niet meer kunnen zien!", - "api-key-regenerated-token-message" : "API-sleutel opnieuw gegenereerd. Zorg ervoor dat je nu je persoonlijke toegangstoken kopieert. Je zult het niet meer kunnen zien!", + "api-key-new-token-message" : "API-sleutel aangemaakt. Zorg ervoor dat u nu uw persoonlijke toegangstoken kopieert. U zult het niet meer kunnen zien!", + "api-key-regenerated-token-message" : "API-sleutel opnieuw gegenereerd. Zorg ervoor dat u nu uw persoonlijke toegangstoken kopieert. U zult het niet meer kunnen zien!", "api-keys-description" : "Project API-sleutels zijn handig wanneer u Tolgee-integraties wilt gebruiken of wanneer u met gegevens in een enkel project wilt werken, zoals sleutels, vertalingen of schermafbeeldingen. Gebruik Personal Access Tokens wanneer u met meerdere projecten of organisaties moet werken.", "api_key_selector_create_new" : "Nieuwe aanmaken", "api-keys-empty-action" : "Nieuwe Project API-sleutel aanmaken", @@ -192,7 +192,7 @@ "authentication_cancelled" : "Authenticatie geannuleerd", "automation_view_title" : "Ontwikkelaarsinstellingen", "back_to_editing" : "Terug naar bewerken", - "bad_credentials" : "Ongeldige referenties", + "bad_credentials" : "Ongeldige inloggegevens", "batch_operation_clear_translations" : "Vertalingen wissen", "batch-operation-dialog-finalizing" : "Afronden...", "batch_operation_progress" : "{progress, number}/{totalItems, number} items", @@ -205,7 +205,7 @@ "batch_operations_delete" : "Verwijder sleutels", "batch_operations_dialog_cancel_job" : "Annuleren", "batch_operations_dialog_minimize" : "Minimaliseren", - "batch_operations_dialog_ok" : "Ok", + "batch_operations_dialog_ok" : "Oké", "batch_operations_machine_translate" : "Machinevertaling", "batch_operations_mark_as_reviewed" : "Markeer als beoordeeld", "batch_operations_mark_as_translated" : "Markeer als vertaald", @@ -235,7 +235,7 @@ "batch_select_no_operation" : "Geen bewerking", "batch_select_placeholder" : "Kies een bewerking...", "billinb_self_hosted_plan_included_mtCredits" : "{mtCredits} MT-credits", - "billinb_self_hosted_plan_included_seats" : "{seats} stoelen", + "billinb_self_hosted_plan_included_seats" : "{seats} gebruikers", "billinb_self_hosted_plan_unlimited_seats" : "Onbeperkte gebruikers", "billing_actual_extra_credits" : "Extra MT-credits", "billing_actual_period" : "Facturatie", @@ -245,15 +245,15 @@ "billing_actual_title" : "Actief abonnement", "billing_actual_used_monthly_credits" : "Gebruikte MT-credits", "billing_actual_used_strings" : "Gebruikte teksten", - "billing_actual_used_strings_with_hint" : "Gebruikte Strings", + "billing_actual_used_strings_with_hint" : "Gebruikte teksten", "billing_actual_used_translations" : "Gebruikte vertalingen", "billing_annual" : "Jaarlijks", "billing_buy_more_mt_slider_value" : "{amount} Credits", - "billing_cancel_dialog_message" : "Je plan blijft actief tot het einde van de huidige periode.", + "billing_cancel_dialog_message" : "Je abonnement blijft actief tot het einde van de huidige periode.", "billing_cancel_dialog_title" : "Weet u zeker dat u het abonnement wilt annuleren?", "billing_credits_explanation" : "MT-kredieten worden gebruikt voor machinevertaalproviders (zoals Google Translate, AWS, DeepL, enz.). Eén krediet ⋍ 1 vertaald teken (Tolgee Translator heeft een andere prijsstelling).", "billing_credits_explanation_tolgee_unified" : "Machinevertaalkredieten (MT) worden gebruikt voor machinevertaalproviders (zoals Tolgee AI, Google Translate, AWS, DeepL, enz.). 1 krediet = 1 te vertalen teken.", - "billing_credits_refill" : "Volgende credits bijvullen", + "billing_credits_refill" : "Credits aangevuld op", "billing_customer_invoices_title" : "Facturen", "billing_customer_portal_button" : "Ga naar klantenportaal", "billing_customer_portal_info" : "Ga naar het klantenportaal om uw betaalmethode of factuurgegevens bij te werken.", @@ -273,13 +273,13 @@ "billing_plan_cancel_success_message" : "Abonnement geannuleerd", "billing_plan_credits_included" : "Maandelijkse MT-credits", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/maand", - "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/maand per extra stoel", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} per extra 1000 MT-credits", + "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/maand per extra gebruiker", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} per extra 1000 MT-credits", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/maand per extra 1000 strings", "billing_plan_resubscribe" : "Abonnement herstellen", "billing_plan_strings_included" : "Inbegrepen strings", - "billing_plan_strings_included_with_hint" : "Inclusief Strings", - "billing_plan_strings_limit" : "Limiet voor teksten", + "billing_plan_strings_included_with_hint" : "Inclusief strings", + "billing_plan_strings_limit" : "Limiet voor strings", "billing_plan_strings_limit_with_hint" : "Limiet voor strings", "billing_plan_subscribe" : "Abonneren", "billing_plan_translation_limit" : "Vertaal limiet", @@ -304,7 +304,7 @@ "billing_subscriptions_plan_includes_title" : "Inbegrepen in dit plan:", "billing_subscriptions_premium_support_feature" : "Premium ondersteuning", "billing_subscriptions_prioritized_feature_requests" : "Prioriteit functieverzoeken", - "billing_subscriptions_project_level_content_storages" : "Aangepaste contentopslag", + "billing_subscriptions_project_level_content_storages" : "Aangepaste inhoudopslag", "billing_subscriptions_standard_support" : "Standaard ondersteuning", "billing_subscriptions_team_training" : "Teamtraining", "billing_subscriptions_webhooks" : "Webhooks", @@ -319,21 +319,21 @@ "cannot_delete_base_language_message" : "Kan basis taal niet verwijderen", "cannot_leave_project_with_organization_role_error_message" : "Kan geen project verlaten dat eigendom is van de organisatie waarvan u lid bent.", "cannot_modify_disabled_translation" : "Kan uitgeschakelde vertaling niet wijzigen", - "cannot_store_file_to_content_storage" : "Kan bestand niet opslaan in contentopslag", + "cannot_store_file_to_content_storage" : "Kan bestand niet opslaan in inhoudsopslag", "clipboard_copy_success" : "Waarde gekopieerd naar klembord!", "confirmation_dialog_cancel" : "Annuleren", "confirmation_dialog_confirm" : "Bevestigen", - "confirmation_dialog_message" : "Weet je het zeker?", + "confirmation_dialog_message" : "Weet u het zeker?", "confirmation_dialog_title" : "Bevestiging", "confirmation_discard_unsaved_confirm" : "Verwijderen", - "confirmation_discard_unsaved_message" : "Weet u zeker dat u niet-opgeslagen wijzigingen wilt negeren?", - "confirmation_discard_unsaved_title" : "Wijzigingen negeren", + "confirmation_discard_unsaved_message" : "Weet u zeker dat u niet-opgeslagen wijzigingen wilt verwerpen?", + "confirmation_discard_unsaved_title" : "Wijzigingen verwerpen", "conflict_is_not_resolved" : "Conflicten zijn niet opgelost", "content_delivery_add_button" : "Inhoudslevering", "content_delivery_create_success" : "Inhoudslevering succesvol aangemaakt!", "content_delivery_create_title" : "Inhoudslevering toevoegen", "content_delivery_delete_success" : "Inhoudslevering succesvol verwijderd!", - "content_delivery_deployment_auto" : "Auto", + "content_delivery_deployment_auto" : "Automatisch", "content_delivery_deployment_manual" : "Handmatig", "content_delivery_description" : "Upload uw lokalisatiebestanden naar de inhoudopslag, zodat u ze rechtstreeks vanuit uw applicatie kunt laden. Het publiceren van de actie kan tot 15 minuten duren (zowel automatisch als handmatig), omdat de standaardopslag in de cache wordt opgeslagen.", "content_delivery_form_cancel" : "Annuleren", @@ -348,10 +348,10 @@ "content_delivery_last_publish_hint" : "Laatste publicatietijd", "content_delivery_not_configured_message" : "Om inhoudsleveringsfuncties in te schakelen, moet u inhoudslevering configureren in de Tolgee-serverinstellingen. Raadpleeg de Tolgee-documentatie daarvoor.", "content_delivery_not_configured_title" : "Inhoudslevering is niet geconfigureerd", - "content_delivery_not_enabled_message" : "Je plan bevat geen meerdere inhoudsleveringsconfiguraties. Upgrade je plan om toegang te krijgen tot deze functie.", + "content_delivery_not_enabled_message" : "Uw abonnement bevat geen meerdere inhoudsleveringsconfiguraties. Upgrade uw plan om toegang te krijgen tot deze functie.", "content_delivery_not_enabled_title" : "Slechts één configuratie voor inhoudslevering is ingeschakeld", "content_delivery_over_limit_title" : "U heeft de limiet overschreden, wijzigingen zijn beperkt", - "content_delivery_page_hint" : "Laad je vertalingen rechtstreeks vanuit Tolgee zodat ze altijd up-to-date zijn", + "content_delivery_page_hint" : "Laad uw vertalingen rechtstreeks vanuit Tolgee zodat ze altijd up-to-date zijn", "content_delivery_publis_success" : "Inhoud succesvol gepubliceerd!", "content_delivery_subtitle" : "Project inhoudslevering", "content_delivery_update_success" : "Inhoudslevering succesvol bijgewerkt!", @@ -388,9 +388,9 @@ "delete_project_dialog_title" : "Project verwijderen", "delete-user-account-button" : "Verwijder mijn account", "delete-user-confirmation-message" : "Wil je echt gebruiker {name} verwijderen?", - "delete-user-confirmation-text" : "Wil je echt je gebruikersaccount verwijderen?", + "delete-user-confirmation-text" : "Wilt u echt uw gebruikersaccount verwijderen?", "developer_menu_content_delivery" : "Inhoudslevering", - "developer_menu_storage" : "Contentopslag", + "developer_menu_storage" : "Inhoudopslag", "developer_menu_webhooks" : "Webhooks", "disabled_languages_none" : "Geen", "disable-user-confirmation-message" : "Weet u zeker dat u gebruiker {name} wilt uitschakelen?", @@ -400,7 +400,7 @@ "ee_licence_key_apply" : "Licentiesleutel toepassen", "ee_licence_key_hint" : "Ga naar Tolgee cloud om uw licentiesleutels te maken of te beheren.", "ee_licence_key_input_label" : "Uw licentiesleutel", - "ee-license-key-confirmation-message" : "Door deze licentiesleutel toe te passen, wordt er {price, number, ::precision-integer currency/EUR} per stoel per maand in rekening gebracht wanneer u het aantal inbegrepen stoelen in uw abonnement overschrijdt ({includedSeats, number}).", + "ee-license-key-confirmation-message" : "Door deze licentiesleutel toe te passen, wordt er {price, number, ::precision-integer currency/EUR} per gebruiker per maand in rekening gebracht wanneer u het aantal inbegrepen gebruikers in uw abonnement overschrijdt ({includedSeats, number}).", "ee-license-key-confirmation-message-estimate" : "Op basis van uw huidige gebruik wordt er aan het einde van deze factureringsperiode {additional, number, ::precision-integer currency/EUR} extra in rekening gebracht.", "ee-license-refresh-success-message" : "Vernieuwd", "ee-license-refresh-tooltip" : "Vernieuw licentie-informatie", @@ -413,7 +413,7 @@ "ee_license_status_label_past_due" : "Verlopen", "ee_license_status_label_unpaid" : "Onbetaald", "email_already_invited_or_member" : "Gebruiker met dit e-mailadres is al uitgenodigd", - "email_not_verified" : "Je e-mail is niet geverifieerd. Controleer je inkomende berichten en volg de instructies.", + "email_not_verified" : "Uw e-mail is niet geverifieerd. Controleer uw inkomende berichten en volg de instructies.", "email_verified_message" : "E-mail is geverifieerd", "email_waiting_for_verification" : "E-mail wachtend op verificatie: {email}", "existing_language_not_selected" : "Taal niet geselecteerd", @@ -422,7 +422,7 @@ "expiration-date-picker-label" : "Selecteer datum", "expiration-label" : "Verloopdatum", "expiration-never-option" : "Verloopt nooit", - "expired_jwt_token" : "Je bent uitgelogd", + "expired_jwt_token" : "U bent uitgelogd", "export_translations_auto_publish_hint" : "Export wordt automatisch uitgevoerd nadat een vertaling is gewijzigd of toegevoegd. Omdat de inhoud sterk wordt gecachet, kan het tot 15 minuten duren voordat de wijzigingen van kracht worden.", "export_translations_auto_publish_label" : "Automatisch publiceren", "export_translations_export_label" : "Exporteren", @@ -438,7 +438,7 @@ "feature-explanation-license-not-active" : "Tolgee-licentie is niet actief", "feature-explanation-license-not-sufficient" : "Uw licentie bevat deze functie niet", "feature-explanation-no-license" : "Je hebt een Tolgee-licentie nodig", - "feature-explanation-plan-not-sufficient" : "Je huidige plan bevat deze functie niet", + "feature-explanation-plan-not-sufficient" : "Uw huidige abonnement bevat deze functie niet", "feature-explanation-setup-license" : "Abonnement upgraden", "feature-explanation-upgrade-subscription" : "Upgrade nu", "feature_not_enabled" : "Functie niet ingeschakeld", @@ -476,18 +476,18 @@ "global_search_organization" : "Zoek organisatie", "global_strings_hint" : "String is een ingevulde vertaling. Een volledig vertaald project zal (aantal sleutels × aantal talen) strings hebben.", "global-upload-not-successful" : "Bestand niet geüpload", - "guide_finish_button" : "Voltooien gids", - "guide_finish_text" : "Gefeliciteerd, je hebt alle stappen voltooid!", + "guide_finish_button" : "Voltooien stappenplan", + "guide_finish_text" : "Gefeliciteerd, u heeft alle stappen voltooid!", "guide_keys" : "Voeg sleutels handmatig toe of gebruik import", "guide_keys_add" : "Sleutels toevoegen\n", "guide_keys_import" : "Importeren", "guide_languages" : "Talen en machinevertaling instellen", "guide_languages_set_up" : "Opzetten", "guide_links_docs_platform" : "Documentatie", - "guide_links_skip" : "Gids overslaan", + "guide_links_skip" : "Stappenplan overslaan", "guide_members" : "Nodig leden uit voor je team", "guide_members_invite" : "Uitnodigen", - "guide_new_project" : "Start je eerste project", + "guide_new_project" : "Start uw eerste project", "guide_new_project_create" : "Project aanmaken", "guide_new_project_demo" : "Probeer demo", "guide_production" : "Voorbereiden voor productie", @@ -541,7 +541,7 @@ "import_override_key_descriptions_label" : "Beschrijvingen van overschrijf-sleutels", "import_override_key_descriptions_label_hint" : "Wanneer ingeschakeld, worden sleutelbeschrijvingen vervangen door die welke zijn verstrekt in de geïmporteerde bestanden.", "import_resolution_accept_imported" : "Geïmporteerd accepteren", - "import_resolution_accept_old" : "Oud accepteren", + "import_resolution_accept_old" : "Accepteer oud", "import_resolve_conflicts_button" : "Conflicten oplossen", "import_resolve_conflicts_empty_list_message" : "Geen conflicten om op te lossen", "import_resolve_conflicts_title" : "Conflicten oplossen", @@ -560,13 +560,13 @@ "import-status-storing-translations" : "Vertalingen opslaan...", "import-successful-message" : "Import succesvol", "import_translations_title" : "Importeer vertalingen", - "integrate-api-key-hidden-description" : "Alleen nieuwe sleutel kan worden onthuld", + "integrate-api-key-hidden-description" : "", "integrate_choose_your_weapon" : "Kies je wapen", "integrate_guides_go_to_docs" : "Ga naar documentatie", "integrate-initial-api-key-description-value" : "Mijn integratie-API-sleutel", "integrate_step_integrate" : "Integreren", "integrate_step_select_api_key" : "Selecteer API-sleutel", - "invalid_connection_string" : "Verbindingssnaar ongeldig", + "invalid_connection_string" : "Verbindingsgegevens ongeldig", "invalid_language_tag" : "Deze taalmarkering volgt niet de BCP 47-standaard. Overweeg om een geldige markering op te geven.", "invalid_otp_code" : "Ongeldige 2FA-code", "invalid_plural_form" : "Ongeldige meervoudsvorm in ICU", @@ -585,12 +585,12 @@ "invite_user_nothing_found" : "Geen uitstaande uitnodigingen", "invite_user_organization_role_label" : "De uitgenodigde gebruiker zal zijn", "invoice_usage_dialog_table_applied_stripe_credits_item" : "Toegepaste credits", - "invoice_usage_dialog_table_count" : "Hoeveelheid", + "invoice_usage_dialog_table_count" : "Aantal", "invoice_usage_dialog_table_item" : "Item", "invoice_usage_dialog_table_mt_credits_item" : "MT-credits", "invoice_usage_dialog_table_no_value" : "-", - "invoice_usage_dialog_table_over_plan" : "Over plan", - "invoice_usage_dialog_table_seats_item" : "Stoelen", + "invoice_usage_dialog_table_over_plan" : "Over abonnement", + "invoice_usage_dialog_table_seats_item" : "Gebruikers", "invoice_usage_dialog_table_subscription_item" : "Abonnement", "invoice_usage_dialog_table_subtotal" : "Subtotaal", "invoice_usage_dialog_table_total" : "Totaal", @@ -635,7 +635,7 @@ "languages_menu_ai_prompt_customization" : "Ai-aanpassing", "languages_menu_machine_translation" : "Machinevertaling", "languages_menu_project_languages" : "Projecttalen", - "languages_modify_ok_button" : "OK", + "languages_modify_ok_button" : "Oké", "languages_permitted_list_all" : "Alle talen", "languages_permitted_list_reset" : "Resetten", "languages_permitted_list_select_all" : "Alle talen", @@ -645,13 +645,13 @@ "leave_project_confirmation_title" : "Project verlaten", "license_key_not_found" : "Ongeldige licentiesleutel", "login_email_label" : "E-mail", - "login_github_login_button" : "GitHub inloggen", + "login_github_login_button" : "Inloggen met GitHub", "login_github_signup_button" : "Registreren met Github", - "login_google_login_button" : "Google inloggen", + "login_google_login_button" : "Inloggen met Google", "login_google_signup_button" : "Registreren met Google", "login_login_button" : "Inloggen", "login_more_title" : "Meer informatie over Tolgee lokalisatietool?", - "login_oauth2_login_button" : "OAuth2 inloggen", + "login_oauth2_login_button" : "Inloggen met OAuth2", "login_oauth2_signup_button" : "Aanmelden met OAuth2", "login_password_label" : "Wachtwoord", "login_reset_password_button" : "Wachtwoord opnieuw instellen", @@ -664,7 +664,7 @@ "machine_translation_title" : "Machinevertaling", "managed-account-field-hint" : "Dit wordt beheerd door uw organisatie.", "managed-account-notice" : "Uw account wordt beheerd door uw organisatie.", - "missing_callback_url" : "Ontbrekende terugroep-URL.", + "missing_callback_url" : "Ontbrekende callback-URL.", "mt_formality_default" : "Standaard", "mt_formality_formal" : "Formeel", "mt_formality_informal" : "Informeel", @@ -676,8 +676,8 @@ "namespace_menu_rename" : "Hernoem namespace", "namespace_rename_cancel" : "Annuleren", "namespace_rename_confirm" : "Hernoemen", - "namespace_rename_confirmation_message" : "Wil je echt de naam van de namespace wijzigen? Zorg ervoor dat je deze wijziging doorvoert in de vertaalde applicatie.", - "namespace_rename_confirmation_title" : "Weet je het zeker?", + "namespace_rename_confirmation_message" : "Wilt u echt de naam van de namespace wijzigen? Zorg ervoor dat u deze wijziging doorvoert in de vertaalde applicatie.", + "namespace_rename_confirmation_title" : "Weet u het zeker?", "namespace_rename_placeholder" : "Nieuwe naam...", "namespace_rename_success" : "Namespace hernoemd", "namespace_rename_title" : "Hernoem namespace", @@ -690,21 +690,12 @@ "namespae_select_title" : "Nieuwe namespace", "new-password-input-label" : "Nieuw wachtwoord", "no_exported_result" : "Niets te exporteren", - "no-permissions-on-the-server" : "Je hebt geen toestemming op deze server. Deze server staat gebruikers niet toe om organisaties te maken.", + "no-permissions-on-the-server" : "U heeft geen toestemming op deze server. Deze server staat gebruikers niet toe om organisaties te maken.", "no-permissions-title" : "Geen rechten", "no_screenshots_yet" : "Er zijn nog geen schermafbeeldingen toegevoegd.", "operation_not_permitted" : "Uw rechten zijn niet voldoende voor deze bewerking.", "operation_not_permitted_error" : "Uw rechten zijn niet voldoende voor deze bewerking.", - "order_translation_dialog_cancel" : "Annuleren", - "order_translation_dialog_consent" : "Ik begrijp dat mijn contactgegevens (e-mail) worden gedeeld met een externe leverancier en ik geef toestemming aan de leverancier om contact met mij op te nemen.", - "order_translation_dialog_note_label" : "Reactie", - "order_translation_dialog_note_placeholder" : "Geef alstublieft extra context over uw project", - "order_translation_dialog_projects_label" : "Selecteer project(en)", - "order_translation_dialog_send_invitation" : "Ik wil {provider} uitnodigen als lid voor de geselecteerde projecten.", - "order_translation_dialog_submit" : "Verzenden", - "order_translation_dialog_subtitle" : "Selecteer de projecten waarvoor u een offerte wilt ontvangen. Tolgee deelt het aantal woorden en talen met de leverancier. De leverancier zal u vervolgens via e-mail een schatting sturen.", - "order_translation_dialog_title" : "Ontvang offerte van {provider}", - "organization_already_subscribed" : "Organisatie heeft zich al geabonneerd op een plan.", + "organization_already_subscribed" : "Organisatie heeft zich al geabonneerd op een abonement.", "organization-billing-self-hosted-active-subscriptions" : "Actieve abonnementen", "organization-billing-self-hosted-cancel-subscription-button" : "Annuleren", "organization-billing-self-hosted-setup-new" : "Nieuw abonnement instellen", @@ -737,7 +728,7 @@ "organizations_add_new" : "Organisatie toevoegen", "organizations_title" : "Organisaties", "organization_subscriptions_cloud_button" : "Cloud", - "organization_subscriptions_self_hosted_ee_button" : "Zelf-gehost", + "organization_subscriptions_self_hosted_ee_button" : "Self-hosted", "organization_subscriptions_title" : "Abonnementen", "organizations-will-be-deleted" : "Deze organisaties worden verwijderd", "organization_updated_message" : "Organisatie-instellingen bijgewerkt", @@ -750,7 +741,7 @@ "organization_users_remove_user" : "Verwijderen", "organization_your_address_to_access_organization" : "Dit wordt de URL van uw organisatie: {address}", "out_of_credits" : "Geen credits meer voor machinevertaling", - "paid-feature-banner-title" : "Je plan bevat deze functie niet.", + "paid-feature-banner-title" : "Uw abonnement bevat deze functie niet.", "parser_duplicate_plural_argument_selector" : "Dubbele optie", "parser_duplicate_select_argument_selector" : "Dubbele optie", "parser_empty_argument" : "Leeg argument", @@ -780,15 +771,15 @@ "Password" : "Wachtwoord", "Password confirmation" : "Bevestiging wachtwoord", "password_reset_message" : "Wachtwoord succesvol opnieuw ingesteld", - "password-strength-medium" : "Medium wachtwoord", + "password-strength-medium" : "Middelmatig wachtwoord", "password-strength-strong" : "Sterk wachtwoord", "password-strength-very-strong" : "Zeer sterk wachtwoord", "password-strength-very-weak" : "Zeer zwak wachtwoord", "password-strength-weak" : "Zwak wachtwoord", - "password-updated" : "Je wachtwoord is bijgewerkt.", + "password-updated" : "Uw wachtwoord is bijgewerkt.", "pat-delete-button" : "Verwijderen", "pat-deleted-message" : "Persoonlijke toegangstoken verwijderd", - "pat-delete-token-confirmation-message" : "Wil je deze token echt verwijderen?", + "pat-delete-token-confirmation-message" : "Wilt u deze token echt verwijderen?", "pat-description-placeholder" : "Mijn coole token", "pat-form-description" : "Beschrijving", "pat-form-generate-submit-button" : "Genereren", @@ -800,8 +791,8 @@ "pat-list-item-regenerate" : "Opnieuw genereren", "pat-list-never-used" : "Nooit gebruikt", "pat_never_expires" : "Verloopt nooit", - "pat-new-token-message" : "Token aangemaakt. Zorg ervoor dat je nu je persoonlijke toegangstoken kopieert. Je zult het niet meer kunnen zien!", - "pat-regenerated-token-message" : "Token opnieuw gegenereerd. Zorg ervoor dat je nu je persoonlijke toegangstoken kopieert. Je zult het niet meer kunnen zien!", + "pat-new-token-message" : "Token aangemaakt. Zorg ervoor dat u nu uw persoonlijke toegangstoken kopieert. U zult het niet meer kunnen zien!", + "pat-regenerated-token-message" : "Token opnieuw gegenereerd. Zorg ervoor dat u nu uw persoonlijke toegangstoken kopieert. U zult het niet meer kunnen zien!", "pats-description" : "Persoonlijke toegangstokens zijn handig wanneer u met meerdere projecten, organisaties wilt werken of wanneer u wilt werken met bronnen die niet toegankelijk zijn met project-API-sleutels. Als u een sleutel wilt verkrijgen voor gebruik met Tolgee-integraties, gebruik dan project-API-sleutels.", "pats-empty-action" : "Nieuwe token aanmaken", "pats-empty-message" : "Er zijn nog geen persoonlijke toegangstokens toegevoegd.", @@ -835,7 +826,7 @@ "permissions_item_members_edit" : "Bewerken", "permissions_item_members_view" : "Bekijken", "permissions_item_project_edit" : "Project bewerken", - "permissions_item_screenshots" : "Schermopnames", + "permissions_item_screenshots" : "Schermafbeeldingen", "permissions_item_screenshots_delete" : "Verwijderen", "permissions_item_screenshots_upload" : "Toevoegen", "permissions_item_screenshots_view" : "Bekijken", @@ -850,7 +841,7 @@ "permissions_item_webhooks_manage" : "Beheer webhooks", "permissions_reset_message" : "Rechten gereset", "permissions_set_message" : "Rechten ingesteld", - "permissions_settings_scopes" : "Bereik:", + "permissions_settings_scopes" : "Omvang:", "permission_type_edit" : "Bewerken", "permission_type_edit_hint" : "Wijzigingen van vertalingen en sleutels", "permission_type_granular" : "Gedetailleerd", @@ -865,7 +856,7 @@ "permission_type_translate_hint" : "Wijzigingen van vertalingen", "permission_type_view" : "Bekijken", "permission_type_view_hint" : "Wijzigingen niet toegestaan", - "plan_has_subscribers" : "Plan heeft abonnees", + "plan_has_subscribers" : "Abonnement heeft abonnees", "plan_limit_dialog_close" : "Sluiten", "plan_limit_dialog_description" : "Deze actie kan niet worden uitgevoerd omdat dit uw huidige planlimiet zou overschrijden", "plan_limit_dialog_go_to_billing" : "Abonnement upgraden", @@ -897,7 +888,7 @@ "project_dashboard_project_owner" : "Project Eigenaar", "project_dashboard_reviewed_percent" : "Beoordeeld", "project_dashboard_show_translations" : "Toon vertalingen", - "project_dashboard_strings_count" : "Teksten", + "project_dashboard_strings_count" : "Strings", "project_dashboard_tag_count" : "Tags", "project_dashboard_title" : "Projectoverzicht", "project_dashboard_translated_percent" : "Vertaald", @@ -913,9 +904,9 @@ "project_languages_default_provider_short" : "Standaard", "project_languages_default_settings" : "Standaardinstellingen", "project_languages_mt_settings_inherited" : "Overgenomen van standaardinstellingen", - "project_languages_mt_settings_provider_not_supported" : "De provider ondersteunt deze taal niet", + "project_languages_mt_settings_provider_not_supported" : "Deze taal wordt niet ondersteund door de provider", "project_languages_new_keys_hint" : "Wanneer de basistranslatie is toegevoegd, zal Tolgee automatisch proberen andere vertalingen te vertalen. Eerst door te zoeken naar een 100% match in het vertaalgeheugen en vervolgens met machinevertaling.", - "project_languages_new_keys_machine_translations_switch" : "Schakel machinevertaling in met primaire provider", + "project_languages_new_keys_machine_translations_switch" : "Schakel machinevertaling in met primaire aanbieder", "project_languages_new_keys_translation_memory_switch" : "Schakel vertaalgeheugen in", "project_languages_other_providers" : "Andere aanbieders", "project_languages_primary_none" : "Geen", @@ -928,7 +919,7 @@ "project_list_translations_button" : "Vertalingen", "project_members_dialog_close_button" : "Sluiten", "project_members_dialog_create_link_button" : "Link maken", - "project_members_dialog_email" : "E-mail", + "project_members_dialog_email" : "E-mailadres", "project_members_dialog_invite_button" : "Uitnodigen", "project_members_dialog_name" : "Naam", "project_members_dialog_permission_title" : "Rechten", @@ -942,7 +933,6 @@ "project_menu_integrate" : "Integreren", "project_menu_languages" : "Talen", "project_menu_members" : "Leden", - "project_menu_order_translation" : "Bestel vertaaldienst", "project_menu_projects" : "Projecten", "project_menu_project_settings" : "Projectinstellingen", "project_menu_translations" : "Vertalingen", @@ -951,14 +941,12 @@ "project_mt_dialog_save_button" : "Opslaan", "project_mt_dialog_service_enabled" : "Ingeschakeld", "project_mt_dialog_service_formality" : "Formaliteit", - "project_mt_dialog_service_not_supported" : "Deze taal wordt niet ondersteund door de provider", + "project_mt_dialog_service_not_supported" : "Deze taal wordt niet ondersteund door de aanbieder", "project_mt_dialog_service_primary" : "Primair", "project_mt_dialog_service_suggested" : "Voorgesteld", "project_mt_dialog_service_suggested_hint" : "Services weergegeven in de suggesties in het vertaalpaneel", "project_mt_dialog_settings_inherited_message" : "Overgenomen van standaardinstellingen", "project_mt_dialog_title" : "Machinevertaling instellingen", - "project_order_translation_subtitle" : "Bestel vertaling van professionele vertalers", - "project_order_translation_title" : "Bestel vertaaldienst", "project_permission_information_text_base_permission_after" : "Dit betekent dat elke gebruiker ten minste deze toestemming heeft. U kunt het niet lager instellen dan dat.", "project_permission_information_text_base_permission_before" : "Dit project maakt deel uit van een organisatie waarvoor basisrechten zijn ingesteld op:", "project_permissions_revoke_user_access_message" : "Weet u zeker dat u de toegang voor gebruiker {userName} wilt intrekken?", @@ -983,26 +971,26 @@ "project_transfer_autocomplete_label" : "Selecteer nieuwe eigenaar", "project_transferred_message" : "Project overgedragen", "project_transfer_rewrite_project_name_to_confirm_message" : "Typ de projectnaam om deze actie te bevestigen.", - "quick_start_highlight_ok" : "Ok", + "quick_start_highlight_ok" : "Oké", "quick_start_highlight_skip" : "Tips overslaan", "quick_start_item_add_language_hint" : "Projecttalen toevoegen of wijzigen", "quick_start_item_automatic_translation_hint" : "Vertaal sleutels automatisch wanneer ze worden aangemaakt of gewijzigd!", "quick_start_item_export_form_hint" : "Exporteer statische bestanden en gebruik ze direct in uw applicatie", "quick_start_item_integrate_form_hint" : "Verbind uw app rechtstreeks met Tolgee en krijg unieke in-context bewerkingsfunctie en nog veel meer!", "quick_start_item_invitations_hint" : "Nodig nieuwe gebruikers uit of beheer bestaande uitnodigingen", - "quick_start_item_machine_translation_hint" : "Stel machinevertaalproviders of autonome vertalingen in", + "quick_start_item_machine_translation_hint" : "Stel machinevertaalaanbieders of autonome vertalingen in", "quick_start_item_members_hint" : "Beheer bestaande leden en hun rechten", "quick_start_item_pick_import_file_hint" : "Laad uw bestaande vertaalbestanden naar het platform", "quick_start_translation_namespace_input_hint" : "Namespace maakt het mogelijk om vertalingen in aparte vertaalbestanden te scheiden. Ze zijn nuttig voor grotere projecten.", "really_leave_organization_confirmation_message" : "Weet u zeker dat u de organisatie wilt verlaten?", "really_remove_user_confirmation" : "Wilt u echt gebruiker {userName} uit de organisatie verwijderen?", "really_want_to_change_base_permission_confirmation" : "Wilt u echt de basisrechten voor deze organisatie wijzigen?", - "really_want_to_change_role_confirmation" : "Wil je echt de rol wijzigen?", + "really_want_to_change_role_confirmation" : "Wilt u echt de rol wijzigen?", "regenerate_api_key_title" : "Project API-sleutel opnieuw genereren", "regenerate_pat_title" : "Token opnieuw genereren", "registrations_not_allowed" : "Registraties zijn niet ingeschakeld", "request_parse_error" : "Er is een interne fout opgetreden", - "reset_password_email_field" : "E-mail", + "reset_password_email_field" : "E-mailadres", "reset_password_send_request_button" : "Verzoek versturen", "reset_password_set_title" : "Nieuw wachtwoord", "reset_password_success_message" : "Verzoek succesvol verzonden! Als u zich heeft aangemeld met dit e-mailadres, ontvangt u een e-mail met een link om uw wachtwoord opnieuw in te stellen. Controleer uw mailbox.", @@ -1011,16 +999,16 @@ "resource_not_found_message" : "Niet gevonden", "revoke_access_confirmation_title" : "Toegang intrekken", "scopes_at_least_one_scope_error" : "Selecteer ten minste één toestemming", - "screenshot_delete_message" : "Wil je de schermafbeelding echt verwijderen?", - "screenshot_delete_title" : "Schermopname verwijderen", - "seats_spending_limit_exceeded" : "Limiet voor uitgaven aan stoelen overschreden", + "screenshot_delete_message" : "Wilt u de schermafbeelding echt verwijderen?", + "screenshot_delete_title" : "Schermafbeelding verwijderen", + "seats_spending_limit_exceeded" : "Limiet voor uitgaven aan gebruikers overschreden", "sensitive-authentication-dialog-title" : "Authenticatie", "sensitive-authentication-message" : "Om door te gaan met deze operatie, gelieve opnieuw te authenticeren. ", - "sensitive-auth-submit-button" : "OK", - "sensitive-dialog-provide-2fa-code" : "Geef een code van je 2-factor authenticator app.", + "sensitive-auth-submit-button" : "Oké", + "sensitive-dialog-provide-2fa-code" : "Geef een code op van de 2-factor authenticator app.", "set_at_least_one_language_error" : "Selecteer ten minste één taal.", "set_at_least_one_state_error" : "Selecteer ten minste één staat", - "sign_up_form_email" : "E-mail", + "sign_up_form_email" : "E-mailadres", "sign_up_form_full_name" : "Volledige naam", "sign_up_form_organization_name" : "Organisatienaam", "sign_up_submit_button" : "Verzenden", @@ -1036,7 +1024,7 @@ "standard_search_label" : "Zoeken...", "storage_add_item" : "Opslag", "storage_create_success" : "Opslag succesvol aangemaakt!", - "storage_create_title" : "Maak contentopslag aan", + "storage_create_title" : "Maak inhoudopslag aan", "storage_delete_success" : "Opslag succesvol verwijderd!", "storage_form_cancel" : "Annuleren", "storage_form_delete" : "Verwijderen", @@ -1049,13 +1037,13 @@ "storage_form_type_azure" : "Azure-blob", "storage_form_type_s3" : "S3-bucket", "storage_item_default" : "Standaard", - "storage_item_delete_dialog_title" : "Verwijder contentopslag", + "storage_item_delete_dialog_title" : "Verwijder inhoudopslag", "storage_item_edit" : "Bewerken", - "storage_not_enabled_message" : "Je plan bevat geen aangepaste inhoudopslag. Upgrade je plan om toegang te krijgen tot deze functie.", + "storage_not_enabled_message" : "Uw abonnement bevat geen aangepaste inhoudopslag. Upgrade uw abonnement om toegang te krijgen tot deze functie.", "storage_over_limit_title" : "U heeft de limiet overschreden, wijzigingen zijn beperkt", - "storage_subtitle" : "Project Contentopslag", + "storage_subtitle" : "Project inhoudopslag", "storage_update_success" : "Inhoudsopslag succesvol bijgewerkt!", - "storage_update_title" : "Bewerk contentopslag", + "storage_update_title" : "Bewerk inhoudopslag", "subscription_already_canceled" : "Abonnement is al geannuleerd", "subscription_not_active" : "Abonnement niet actief", "theme_dark_label" : "Donker", @@ -1081,7 +1069,6 @@ "translation_failed" : "Vertaling mislukt", "translation_grid_key_text" : "Sleutel", "Translation grid - Successfully deleted!" : "Vertalingen verwijderd!", - "translation_provider_get_quote" : "Krijg kostenraming", "translations_auto_translated_provider" : "Automatisch vertaald met {provider}", "translations_auto_translated_tm" : "Automatisch vertaald met vertaalgeheugen", "translations_cell_cancel" : "Annuleren", @@ -1096,7 +1083,7 @@ "translations_cell_tab_history" : "Geschiedenis", "translations_clear_selection" : "Selectie wissen", "translations_comments_delete_button" : "Verwijderen", - "translations_comments_delete_confirmation" : "Wil je deze reactie echt verwijderen?", + "translations_comments_delete_confirmation" : "Wilt u deze reactie echt verwijderen?", "translations_comments_load_more" : "Meer laden", "translations_comments_needs_resolution" : "Niet opgelost", "translations_comments_previous_comments" : "Vorige opmerkingen", @@ -1111,15 +1098,15 @@ "translations_filter_placeholder" : "Filteren...", "translations_filters_heading_clear" : "Filters wissen", "translations_filters_heading_namespaces" : "Namespaces", - "translations_filters_heading_screenshots" : "Schermopnames", + "translations_filters_heading_screenshots" : "Schermafbeeldingen", "translations_filters_heading_states" : "Staten", "translations_filters_heading_tags" : "Tags", "translations_filters_heading_translations" : "Vertalingen", "translations_filters_missing_translation" : "Ontbrekende vertaling", - "translations_filters_no_screenshots" : "Geen schermopnames", + "translations_filters_no_screenshots" : "Geen schermafbeeldingen", "translations_filters_something_outdated" : "Verouderde vertaling", "translations_filters_text" : "{count, plural, one {# woord} other {# woorden} }", - "translations_filters_with_screenshots" : "Met schermopnames", + "translations_filters_with_screenshots" : "Met schermafbeeldingen", "translations-history-differences-toggle" : "Verschillen", "translations_history_load_more" : "Meer laden", "translations_history_previous_items" : "Vorige items", @@ -1135,7 +1122,7 @@ "translation_single_label_namespace" : "Namespace", "translation_single_label_plural_hint" : "Wijzig de variabelenaam die wordt gebruikt voor het meervoud", "translation_single_label_plural_variable" : "Variabelnaam", - "translation_single_label_screenshots" : "Schermopnames", + "translation_single_label_screenshots" : "Schermafbeeldingen", "translation_single_label_tags" : "Tags", "translation_single_namespace_hint" : "Gebruik namespaces voor grote projecten, waarin u sleutels in meerdere bestanden wilt scheiden", "translation_single_no_permission_create" : "U heeft onvoldoende rechten om een nieuwe sleutel toe te voegen", @@ -1160,7 +1147,7 @@ "translations_nothing_found_action" : "Filters wissen", "translations_no_translations" : "Geen vertalingen", "translations_no_translations_action" : "Nieuwe vertaling toevoegen", - "translations_no_translations_integrate" : "Integratiegids", + "translations_no_translations_integrate" : "Integratieplan", "translation_spending_limit_exceeded" : "Limiet voor uitgaven overschreden", "translations_results_count" : "{count, plural, one {1 sleutel} other {# sleutels}}", "translations" : { @@ -1174,14 +1161,13 @@ } } }, - "translations_screenshots_popover_title" : "Schermopnames", - "translations_screenshots_tooltip" : "Schermopnames", + "translations_screenshots_popover_title" : "Schermafbeeldingen", + "translations_screenshots_tooltip" : "Schermafbeeldingen", "translations_select_all" : "Selecteer alles", "translations_selected_count" : "Geselecteerd {count, number} / {total, number}", "translations_shortcuts_in_editor_title" : "In editor", "translations_shortcuts_in_list_title" : "In lijst", "translations_shortcuts_move" : "Verplaatsen", - "translations_shortcuts_title" : "Sneltoetsen", "translations_tag_create" : "\"{tag}\" toevoegen", "translations_tag_label" : "label", "translations_tags_no_results" : "Niets gevonden", @@ -1259,7 +1245,7 @@ "webhook_secret_title" : "Webhook-geheim", "webhooks_failing_hint" : "Webhook is mislukt bij de laatste uitvoering", "webhooks_last_run_hint" : "Laatste uitvoeringstijd", - "webhooks_not_enabled_message" : "Je plan bevat de webhooks functie niet. Upgrade je plan om toegang te krijgen tot deze functie.", + "webhooks_not_enabled_message" : "Uw abonnement bevat de webhooks functie niet. Upgrade uw abonnement om toegang te krijgen tot deze functie.", "webhooks_over_limit_title" : "U heeft de limiet overschreden, wijzigingen zijn beperkt", "webhooks_subtitle" : "Project Webhooks", "webhook_test_fail" : "Test mislukt!", diff --git a/webapp/src/i18n/no.json b/webapp/src/i18n/no.json index fe1092292e..8a512ada5d 100644 --- a/webapp/src/i18n/no.json +++ b/webapp/src/i18n/no.json @@ -1,29 +1,29 @@ { - "access_revoked_message" : "Tilgang fjernet", + "access_revoked_message" : "Tilgang opphevet", "account-deleted-message" : "Konto slettet", - "account-security-mfa" : "To-faktor autentisering", + "account-security-mfa" : "Tofaktorautentisering", "account-security-mfa-disabled-success" : "Tofaktorautentisering ble deaktivert.", "account-security-mfa-disable-mfa" : "Deaktiver tofaktorautentisering", "account-security-mfa-disable-mfa-button" : "Deaktiver 2FA", - "account-security-mfa-enabled-success" : "Tofaktorautentisering ble aktivert.", - "account-security-mfa-enable-manual-entry" : "Tofaktorautentisering ble aktivert.", + "account-security-mfa-enabled-success" : "Tofaktorautentisering har blitt aktivert.", + "account-security-mfa-enable-manual-entry" : "Alternativt kan du skrive inn følgende nøkkel manuelt:", "account-security-mfa-enable-mfa" : "Aktiver tofaktorautentisering", "account-security-mfa-enable-mfa-button" : "Aktiver 2FA", - "account-security-mfa-enable-step-one" : "Skann først QR-koden nedenfor i din favoritt app for autentisering på telefonen din.", + "account-security-mfa-enable-step-one" : "Først skanner du QR-koden nedenfor med din favoritt app for autentisering på telefon.", "account-security-mfa-enable-step-two" : "Deretter skriver du inn den seks-sifrede koden som applikasjonen genererer og passordet ditt for å fullføre aktiveringen av tofaktorautentisering for kontoen din.", "account-security-mfa-otp-code" : "2FA-kode", - "account-security-mfa-recovery-codes" : "Gjenopprettingskoder for 2FA", - "account-security-mfa-recovery-codes-description" : "Her er gjenopprettingskodene dine. Sørg for å lagre dem et trygt sted.", - "account-security-mfa-recovery-info" : "Gjenopprettingskoder lar deg logge inn på kontoen din hvis du mister tilgangen til autentiseringsappen din. Vi anbefaler på det sterkeste at du lagrer disse kodene sikkert for å unngå å bli utestengt fra kontoen din.", + "account-security-mfa-recovery-codes" : "2FA gjenopprettingskoder", + "account-security-mfa-recovery-codes-description" : "Nedenfor er gjenopprettingskodene dine. Sørg for å lagre dem et trygt sted.", + "account-security-mfa-recovery-info" : "Gjenopprettingskoder lar deg logge inn på kontoen din hvis du mister tilgangen til autentiseringsappen din. Vi anbefaler på det sterkeste at du lagrer disse kodene på en sikker måte for å unngå å bli utestengt fra kontoen din.", "account-security-mfa-recovery-info-invalidate" : "Merk: å se gjennom gjenopprettingskodene dine vil gjøre tidligere genererte gjenopprettingskoder ugyldige. Kun de nylig genererte kodene vil være gyldige.", "account-security-mfa-status-disabled" : "To-faktor autentisering er deaktivert.", "account-security-mfa-status-enabled" : "To-faktor autentisering er aktivert.", - "account-security-mfa-view-recovery" : "Vis koder for gjenoppretting", - "account-security-set-password" : "Angi passord", - "account-security-set-password-instructions-sent" : "Vi har sendt deg en e-post med instruksjonene du skal følge.", + "account-security-mfa-view-recovery" : "Se gjennopprettingskoder", + "account-security-set-password" : "Sett passord", + "account-security-set-password-instructions-sent" : "Vi har sendt deg en e-post med instruksjoner til å følge.", "account-security-set-password-third-party-info" : "Din konto bruker for øyeblikket en tredjeparts påloggingsflyt. For å administrere kontoens sikkerhetsinnstillinger, må du først sette et passord. Når et passord er satt, vil du kunne logge inn via både det nylig opprettede passordet og den tredjeparts kontoen du bruker for øyeblikket.", "active-plan-current-period" : "Gjeldende periode: {start, date} - {end, date}", - "active-plan-estimated-costs-description" : "Denne verdien kan endre seg i henhold til din månedlige bruk. Du vil imidlertid bli belastet dette beløpet når bruken din forblir konstant for resten av denne faktureringsperioden.", + "active-plan-estimated-costs-description" : "Denne verdien kan endre seg i henhold til din månedlige bruk. Men du vil bli belastet dette beløpet når bruken din forblir konstant for resten av denne faktureringsperioden.", "active-plan-estimated-costs-show-usage-button-tooltip" : "Vis bruk", "active-plan-estimated-costs-title" : "Estimerte kostnader", "active-plan-license-key-button" : "Vis lisensnøkkel", @@ -32,37 +32,37 @@ "activity_batch_operation_auto_translate" : "Automatisk oversatt {TranslationCount, plural, one {# oversettelse} other {# oversettelser}}", "activity_batch_operation_clear_translations" : "Fjernet {TranslationCount, plural, one {# oversettelse} other {# oversettelser}} i gruppebehandling", "activity_batch_operation_copy_translations" : "Kopierte {TranslationCount, plural, one {# oversettelse} other {# oversettelser}} i gruppebehandling", - "activity_batch_operation_machine_translate" : "Maskinoversatt {TranslationCount, plural, one {# streng} other {# strenger}} i gruppebehandling", + "activity_batch_operation_machine_translate" : "Maskinoversatte {TranslationCount, plural, one {# streng} other {# strenger}} i gruppebehandling", "activity_batch_operation_pre_translate_by_tm" : "Forhånds-oversatt {TranslationCount, plural, one {# oversettelse} other {# oversettelser}} av TM i gruppebehandling", - "activity_batch_operation_set_keys_namespace" : "Sett navnerom for {KeyCount, plural, one {# nøkkel} other {# nøkler}} i gruppe", - "activity_batch_operation_set_translation_state" : "Sett status for {TranslationCount, plural, one {# oversettelse} other {# oversettelser}} i gruppebehandling", + "activity_batch_operation_set_keys_namespace" : "Sett navnerom for {KeyCount, plural, one {# nøkkel} other {# nøkler}} i gruppebehandling", + "activity_batch_operation_set_translation_state" : "Sett tilstand for {TranslationCount, plural, one {# oversettelse} other {# oversettelser}} i gruppebehandling", "activity_batch_operation_tag_keys" : "La til tagg på {KeyMetaCount, plural, one {# nøkkel} other {# nøkler}} i gruppebehandling", "activity_batch_operation_untag_keys" : "Fjernet tagg fra {KeyMetaCount, plural, one {# nøkkel} other {# nøkler}} i gruppebehandling", - "activity_complex_edit" : "Redigerte nøkkel", + "activity_complex_edit" : "Nøkkel redigert", "activity_create_key" : "Opprettet nøkkel", - "activity_create_language" : "Opprettet språk", - "activity_create_project" : "Opprettet prosjekt", + "activity_create_language" : "Språk opprettet", + "activity_create_project" : "Prosjekt opprettet", "activity_date_today" : "I dag", "activity_delete_language" : "Slettet språk", - "activity_dismiss_auto_translated_state" : "Automatisk oversatt tilstand avvist", + "activity_dismiss_auto_translated_state" : "Automatisk oversatt status avvist", "activity_edit_language" : "Oppdaterte språk", - "activity_edit_namespace" : "Namespace redigert", + "activity_edit_namespace" : "Navnerom redigert", "activity_edit_project" : "Prosjekt redigert", "activity_entity_key" : "Nøkkel", - "activity_entity_key_meta" : "Nøkkel attributter", - "activity_entity_key_meta.description" : "Beskrivelse", + "activity_entity_key_meta" : "Nøkkelattributter", + "activity_entity_key_meta.description" : "beskrivelse", "activity_entity_key_meta.tags" : "tagger", "activity_entity_key.name" : "navn", - "activity_entity_key.namespace" : "namespace", + "activity_entity_key.namespace" : "navnerom", "activity_entity_language" : "Språk", - "activity_entity_namespace" : "Namespace", + "activity_entity_namespace" : "Navnerom", "activity_entity_namespace.name" : "navn", - "activity_entity_params" : "Inndata parametere", - "activity_entity_params.namespace" : "namespace", + "activity_entity_params" : "Inndata-parametere", + "activity_entity_params.namespace" : "navnerom", "activity_entity_params.source_language_id" : "kildespråk", "activity_entity_params.state" : "tilstand", "activity_entity_params.tags" : "tagger", - "activity_entity_params.target_language_ids" : "målspråk", + "activity_entity_params.target_language_ids" : "målrettede språk", "activity_entity_project" : "Prosjekt", "activity_entity_project.language" : "grunnspråk", "activity_entity_project.name" : "navn", @@ -71,25 +71,25 @@ "activity_entity_translation_comment" : "Kommentar", "activity_entity_translation.flag_emoji" : "flagg", "activity_entity_translation.name" : "navn", - "activity_entity_translation.tag" : "tag", - "activity_import" : "{KeyCount, plural, one {Importerte en nøkkel} other {Importerte # nøkler} } ({TranslationCount, plural, one {1 oversettelse} other {# oversettelser} })", + "activity_entity_translation.tag" : "tagg", + "activity_import" : "{KeyCount, plural, \n one {Importerte én nøkkel}\n other {Importerte # nøkler}\n} ({TranslationCount, plural, \n one {1 oversettelse}\n other {# oversettelser}\n})", "activity_key_delete" : "Slettet {KeyCount, plural, one {1 nøkkel} other {# nøkler}}", - "activity_key_name_edit" : "Redigerte nøkkelnavnet", - "activity_key_tags_edit" : "Nøkkel tagger oppdatert", - "activity_screenshot_add" : "La til skjermbilde", + "activity_key_name_edit" : "Redigert nøkkelnavn", + "activity_key_tags_edit" : "Nøkkelmerker oppdatert", + "activity_screenshot_add" : "Skjermbilde lagt til", "activity_screenshot_delete" : "Fjernet skjermbilde", - "activity_set_outdated_flag" : "Oppdatert tilstand", - "activity_set_translation" : "Oppdaterte oversettelsen", - "activity_set_translation_state" : "Sett status på oversettelse", - "activity_translation_comment_add" : "La til kommentar", + "activity_set_outdated_flag" : "Oppdatert status", + "activity_set_translation" : "Oppdatert oversettelse", + "activity_set_translation_state" : "Sett oversettelsestilstand", + "activity_translation_comment_add" : "Kommentar lagt til", "activity_translation_comment_delete" : "Kommentar slettet", - "activity_translation_comment_set_state" : "Status på kommentar ble endret", + "activity_translation_comment_set_state" : "Tilstand for kommentar endret", "activity_translation_history_add" : "Oversettelse opprettet", "activity_translation_history_modify" : "Oversettelse endret", "activity_translation_not_outdated" : "Ikke utdatert", "activity_translation_outdated" : "Utdatert", "add_screenshots_message" : "Legg til noen ved å slippe eller klikke på pluss.", - "administration-access-message" : "Du ser denne siden som serveradministrator", + "administration-access-message" : "Du får tilgang til denne siden som serveradministrator", "administration_cloud_plan_create" : "Opprett plan", "administration_cloud_plan_created_success" : "Plan opprettet", "administration_cloud_plan_deleted_success" : "Plan slettet", @@ -125,6 +125,7 @@ "administration_ee_plan_create" : "Opprett plan", "administration_ee_plan_created_success" : "Plan opprettet", "administration_ee_plan_deleted_success" : "Plan slettet", + "administration_ee_plan_delete_message" : "Ønsker du virkelig å slette denne prissettingsplanen?", "administration_ee_plan_edit" : "Rediger plan", "administration_ee_plan_edit_button" : "Rediger", "administration_ee_plan_field_free" : "Gratis", @@ -150,18 +151,18 @@ "administration_organizations_settings" : "Innstillinger", "administration_role_set_success" : "Rolle endret", "administration_title" : "Serveradministrasjon", - "administration_user_debug" : "Feilsøk konto", + "administration_user_debug" : "Feilsøkingskonto", "administration_user_deleted_message" : "Bruker slettet", "administration_user_disabled_message" : "Bruker deaktivert", "administration_user_enabled_message" : "Bruker aktivert", "administration_user_role_admin" : "Admin", "administration_user_role_user" : "Bruker", "administration_users" : "Brukere", - "ai_customization_description" : "Fordi Tolgees maskinoversettelse er basert på kunstig intelligens, kan du forbedre oversettelsen ved å gi en beskrivelse av prosjektet ditt eller legge til en språkspesifikk merknad.", + "ai_customization_description" : "Fordi Tolgees maskinoversettelse er basert på kunstig intelligens, kan du forbedre oversettelsen ved å gi en beskrivelse av prosjektet ditt eller legge til en språkspesifikk notat.", "ai_customization_not_enabled_message" : "Oppgrader planen din for å få tilgang til AI-tilpasningsfunksjonen.", "ai_customization_title" : "AI-tilpasning", "ai_tips_label" : "Tips", - "announcement_feature_ai_customization" : "Tilpasninger for Tolgee AI-oversetteren er lansert", + "announcement_feature_ai_customization" : "Tilpasninger for Tolgee AI-oversetter er lansert", "announcement_feature_batch_operations" : "Vi har lansert batch-operasjoner", "announcement_feature_content_delivery_and_webhooks" : "Øk hastigheten på lokaliseringen din med nylig utgitte Content Delivery og Webhooks!", "announcement_feature_mt_formality" : "Maskinoversettelser støtter nå formell tone", @@ -184,8 +185,8 @@ "api-keys-description" : "Prosjekt-API-nøkler er nyttige når du vil bruke Tolgee-integrasjoner eller når du trenger å jobbe med data i et enkelt prosjekt som nøkler, oversettelser eller skjermbilder. Når du trenger å jobbe med flere prosjekter eller organisasjoner, bruk Personlige tilgangstokens.", "api_key_selector_create_new" : "Opprett ny", "api-keys-empty-action" : "Opprett ny prosjekt-API-nøkkel", - "api-keys-empty-message" : "Ingen API-nøkkel for prosjektet er lagt til ennå", - "api_keys_title" : "Mine API nøkler", + "api-keys-empty-message" : "Ingen prosjekt-API-nøkkel er lagt til ennå", + "api_keys_title" : "Prosjekt-API-nøkler", "api_key_successfully_edited" : "API-nøkkelen ble redigert!", "api_key_successfully_generated" : "API-nøkkelen ble opprettet", "authentication_cancelled" : "Autentisering avbrutt", @@ -208,7 +209,7 @@ "batch_operations_machine_translate" : "Maskinoversettelse", "batch_operations_mark_as_reviewed" : "Merk som gjennomgått", "batch_operations_mark_as_translated" : "Merk som oversatt", - "batch_operations_outdated_message" : "Oversettelser kan være utdaterte", + "batch_operations_outdated_message" : "Oversettelser kan være utdatert", "batch_operations_pre_translate" : "Forhånds-oversett ved TM", "batch_operations_refresh_button" : "Oppdater", "batch_operations_remove_tags" : "Fjern merkelapper", @@ -221,27 +222,27 @@ "batch_operation_tag_input_placeholder" : "Søk etter tag...", "batch_operation_tag_remove_input_placeholder" : "Søk etter tag...", "batch_operation_type_automation" : "Automatisering", - "batch_operation_type_auto_translate" : "Automatisk oversett", + "batch_operation_type_auto_translate" : "Automatisk oversettelse", "batch_operation_type_clear_translations" : "Slett oversettelser", "batch_operation_type_copy_translations" : "Kopier oversettelser", "batch_operation_type_delete_keys" : "Slett nøkler", "batch_operation_type_machine_pre_translate_by_tm" : "Forhånds-oversett ved TM", "batch_operation_type_machine_translation" : "Maskinoversettelse", "batch_operation_type_set_keys_namespace" : "Endre navneområde", - "batch_operation_type_set_translations_state" : "Sett status på oversettelser", + "batch_operation_type_set_translations_state" : "Sett oversettelsestilstand", "batch_operation_type_tag_keys" : "Merk nøkler", "batch_operation_type_untag_keys" : "Fjern nøkler", "batch_select_no_operation" : "Ingen operasjon", "batch_select_placeholder" : "Velg operasjon...", - "billinb_self_hosted_plan_included_mtCredits" : "{mtCredits} MT-kreditter", + "billinb_self_hosted_plan_included_mtCredits" : " {mtCredits} MT-kreditter", "billinb_self_hosted_plan_included_seats" : "{seats} seter", "billinb_self_hosted_plan_unlimited_seats" : "Ubegrenset antall seter", "billing_actual_extra_credits" : "Ekstra MT-kreditter", "billing_actual_period" : "Fakturering", - "billing_actual_period_end" : "Periodeslutt", - "billing_actual_period_finish" : "abonnementet vil bli kansellert ved periodens slutt", - "billing_actual_period_renewal" : "automatisk fornyelse", - "billing_actual_title" : "Aktiv plan", + "billing_actual_period_end" : "Periode slutt", + "billing_actual_period_finish" : "Abonnementet vil bli kansellert ved periodeslutt", + "billing_actual_period_renewal" : "Automatisk fornyelse", + "billing_actual_title" : "Aktivt abonnement", "billing_actual_used_monthly_credits" : "Brukte MT-kreditter", "billing_actual_used_strings" : "Brukte strenger", "billing_actual_used_strings_with_hint" : "Brukte strenger", @@ -259,30 +260,30 @@ "billing_customer_portal_title" : "Kundeportal", "billing_extra_credits_buy" : "Kjøp kreditter", "billing_extra_credits_title" : "Kjøp ekstra MT-kreditter", - "billing_invoices_empty" : "Ingen fakturaer enda", + "billing_invoices_empty" : "Ingen fakturaer ennå", "billing_invoices_show_usage_button" : "Vis bruk", "billing_monthly" : "Månedlig", - "billing_mt_credit_purchase_success_message" : "Kreditter kjøpt", + "billing_mt_credit_purchase_success_message" : "Kreditter kjøpt vellykket", "billing_organization_already_subscribed" : "Organisasjonen har allerede abonnert", "billing_period_annual" : "årlig fakturering", "billing_period_monthly_price" : "{price, number, ::precision-integer currency/EUR}/mnd", "billing_period_monthly_switch" : "Bytt til månedlig fakturering", "billing_period_yearly_switch" : "Bytt til årlig fakturering", "billing_plan_cancel" : "Avbryt", - "billing_plan_cancel_success_message" : "Abonnementsplan kansellert", + "billing_plan_cancel_success_message" : "Faktureringsplan kansellert", "billing_plan_credits_included" : "Månedlige MT-kreditter", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/mnd", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/mnd per ekstra sete", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} per ekstra 1000 MT-kreditter", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} per ekstra 1000 MT-kreditter", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/mnd per ekstra 1000 strenger", "billing_plan_resubscribe" : "Gjenopprett abonnement", "billing_plan_strings_included" : "Inkluderte strenger", - "billing_plan_strings_included_with_hint" : "Inkludert strenger", + "billing_plan_strings_included_with_hint" : "Inkluderte strenger", "billing_plan_strings_limit" : "Strenggrense", - "billing_plan_strings_limit_with_hint" : "Strenger grense", + "billing_plan_strings_limit_with_hint" : "Strenggrense ", "billing_plan_subscribe" : "Abonner", "billing_plan_translation_limit" : "Oversettelsesgrense", - "billing_plan_update_success_message" : "Faktureringsplanen ble oppdatert", + "billing_plan_update_success_message" : "Abonnementsplanen er oppdatert", "billing-progress-label-over" : "Over abonnement: {value, number}", "billing-progress-label-unused" : "Forhåndsbetalt: {value, number}", "billing-progress-label-used" : "Brukt fra abonnement: {value, number}", @@ -294,27 +295,27 @@ "billing_subscriptions_assisted_updates_feature" : "Assisterte oppdateringer", "billing_subscriptions_backup_configuration_feature" : "Sikkerhetskopieringskonfigurasjon", "billing_subscriptions_dedicated_slack_channel" : "Eget Slack-kanal", - "billing_subscriptions_deployment_assistance" : "Implementeringshjelp", + "billing_subscriptions_deployment_assistance" : "Implementeringsassistanse", "billing_subscriptions_ee_license_inactive" : "Lisensen din er inaktiv.", "billing_subscriptions_free_plan_description" : "Denne planen er gratis for deg.", "billing_subscriptions_granular_permissions_feature" : "Detaljerte tillatelser", - "billing_subscriptions_multiple_content_delivery_configs" : "Flere konfigurasjoner for innholdsdistribusjon", + "billing_subscriptions_multiple_content_delivery_configs" : "Flere konfigurasjoner for levering av innhold", "billing_subscriptions_pay_for_what_you_use" : "Betal kun for det du bruker.", "billing_subscriptions_plan_includes_title" : "Inkludert i denne planen:", "billing_subscriptions_premium_support_feature" : "Premium-støtte", "billing_subscriptions_prioritized_feature_requests" : "Prioriterte funksjonsforespørsler", "billing_subscriptions_project_level_content_storages" : "Tilpasset innholdslagring", "billing_subscriptions_standard_support" : "Standard støtte", - "billing_subscriptions_team_training" : "Teamopplæring", + "billing_subscriptions_team_training" : "Lagopplæring", "billing_subscriptions_webhooks" : "Webhooks", - "billing_upgrade_preview_confirm_button" : "Bytt plan", + "billing_upgrade_preview_confirm_button" : "Endre plan", "billing_upgrade_preview_dialog_amount_due_text" : "Du vil bli belastet {amountDue, number, :: currency/EUR}", - "billing_upgrade_preview_dialog_applied_credit_message" : "(Når du nedgraderer til et lavere abonnement, vil beløpet bli lagret som kreditt som kan brukes i fremtidige faktureringsperioder.)", - "billing_upgrade_preview_dialog_info" : "Når du abonnerer på en annen plan i løpet av en løpende faktureringsperiode, vil beløpet du blir belastet reduseres med tilsvarende beløp for ubrukt del av gjeldende faktureringsperiode.", + "billing_upgrade_preview_dialog_applied_credit_message" : "(Når du nedgraderer til en lavere abonnementsplan, vil beløpet bli lagret som kreditter som kan brukes i fremtidige faktureringsperioder.)", + "billing_upgrade_preview_dialog_info" : "Hvis du abonnerer på en annen plan i løpet av en løpende faktureringsperiode, vil beløpet du blir belastet reduseres med tilsvarende beløp for ubrukt del av gjeldende faktureringsperiode.", "billing_upgrade_preview_dialog_title" : "Forhåndsvisning av abonnementsoppdatering", "billing_upgrade_preview_dialog_total" : "Totalt {total, number, :: currency/EUR}", "cannot_add_api_key_without_project_message" : "Kan ikke legge til API-nøkkel uten prosjekt :( Legg til prosjekt først", - "cannot_change_your_own_access_tooltip" : "Kan ikke tilbakekalle tilgangen din selv", + "cannot_change_your_own_access_tooltip" : "Kan ikke tilbakekalle tilgangen til deg selv", "cannot_delete_base_language_message" : "Kan ikke slette grunnspråket", "cannot_leave_project_with_organization_role_error_message" : "Kan ikke forlate prosjektet som eies av organisasjonen du er medlem av.", "cannot_modify_disabled_translation" : "Kan ikke endre deaktivert oversettelse", @@ -345,7 +346,7 @@ "content_delivery_item_edit" : "Rediger", "content_delivery_item_publish" : "Publiser", "content_delivery_last_publish_hint" : "Siste publiseringstidspunkt", - "content_delivery_not_configured_message" : "For å aktivere innholdsdistribusjonsfunksjoner må du konfigurere innholdsdistribusjon i Tolgee-serverinnstillingene. Sjekk Tolgee-dokumentasjonen for dette.", + "content_delivery_not_configured_message" : "For å aktivere innholdsleveransefunksjoner må du konfigurere innholdsleveranse i Tolgee-serverinnstillingene. Sjekk Tolgee-dokumentasjonen for dette.", "content_delivery_not_configured_title" : "Innholdsdistribusjon er ikke konfigurert", "content_delivery_not_enabled_message" : "Ditt abonnement inkluderer ikke flere konfigurasjoner for innholdsdistribusjon. Oppgrader abonnementet ditt for å få tilgang til denne funksjonen.", "content_delivery_not_enabled_title" : "Bare én konfigurasjon for innholdsdistribusjon er aktivert", @@ -356,7 +357,7 @@ "content_delivery_update_success" : "Innholdlevering oppdatert!", "content_delivery_update_title" : "Rediger innholdlevering", "content_storage_config_invalid" : "Lagringskonfigurasjonen er ugyldig", - "content_storage_is_in_use" : "Innholdslagring kan ikke slettes fordi den er i bruk!", + "content_storage_is_in_use" : "Innholdslagringen kan ikke slettes fordi den er i bruk!", "content_storage_test_failed" : "Lagringstesten mislyktes", "create_language_close_button" : "Lukk", "create_language_title" : "Legg til språk", @@ -380,10 +381,10 @@ "dashboard_billing_used_strings" : "Brukte strenger: {available, number, ::precision-integer} / {max, number, ::precision-integer}", "dashboard_billing_used_translations" : "Brukte oversettelser: {available, number, ::precision-integer} / {max, number, ::precision-integer}", "delete_language_button" : "Slett språk", - "delete_language_confirmation" : "Er du sikker på at du vil slette dette språket og alle oversettelser?", - "delete_organization_confirmation_message" : "Er du sikker på at du vil slette organisasjonen og alle prosjektene?", + "delete_language_confirmation" : "Ønsker du virkelig å slette dette språket og alle oversettelsene?", + "delete_organization_confirmation_message" : "Er du sikker på at du vil slette organisasjonen og alle dens prosjekter?", "delete_project_button" : "Slett prosjektet", - "delete_project_confirmation_message" : "Er du sikker på at du vil slette {name} prosjektet?", + "delete_project_confirmation_message" : "Er du sikker på at du vil slette prosjektet {name}?", "delete_project_dialog_title" : "Slett prosjektet", "delete-user-account-button" : "Slett kontoen min", "delete-user-confirmation-message" : "Ønsker du virkelig å slette brukeren {name}?", @@ -392,19 +393,19 @@ "developer_menu_storage" : "Innholdslagring", "developer_menu_webhooks" : "Webhooks", "disabled_languages_none" : "Ingen", - "disable-user-confirmation-message" : "Vil du virkelig deaktivere brukeren {name}?", - "edit_api_key_title" : "Rediger API nøkkel", + "disable-user-confirmation-message" : "Ønsker du virkelig å deaktivere brukeren {name}?", + "edit_api_key_title" : "Rediger prosjekt-API-nøkkel", "edit_organization_title" : "Organisasjonsprofil", "edit_pat_title" : "Rediger personlig tilgangsnøkkel", "ee_licence_key_apply" : "Bruk lisensnøkkel", - "ee_licence_key_hint" : "For å opprette eller administrere lisensnøklene dine, gå til Tolgee sky.", + "ee_licence_key_hint" : "For å opprette eller administrere lisensnøklene dine, gå til Tolgee cloud.", "ee_licence_key_input_label" : "Din lisensnøkkel", "ee-license-key-confirmation-message" : "Ved å bruke denne lisensnøkkelen, vil du bli belastet {price, number, ::precision-integer currency/EUR} per sete per måned når du overstiger {includedSeats, number} inkluderte seter i abonnementsplanen din.", "ee-license-key-confirmation-message-estimate" : "Basert på din nåværende bruk, vil du bli belastet med ytterligere {additional, number, ::precision-integer currency/EUR} innen utgangen av denne faktureringsperioden.", "ee-license-refresh-success-message" : "Oppdatert", "ee-license-refresh-tooltip" : "Oppdater lisensinformasjon", - "ee-license-release-key-button" : "Frigjør lisensnøkkel", - "ee-license-release-key-confirmation" : "Vil du virkelig frigjøre lisensnøkkelen din? Du vil ikke lenger kunne bruke betalte funksjoner.", + "ee-license-release-key-button" : "Slipp lisensnøkkel", + "ee-license-release-key-confirmation" : "Ønsker du virkelig å frigjøre lisensnøkkelen din? Du vil ikke lenger kunne bruke betalte funksjoner.", "ee_license_status_label_active" : "Aktiv", "ee_license_status_label_canceled" : "Avbrutt", "ee_license_status_label_error" : "Ukjent feil", @@ -414,7 +415,7 @@ "email_already_invited_or_member" : "En bruker med denne e-postadressen er allerede invitert", "email_not_verified" : "E-posten din er ikke verifisert. Sjekk innboksen din og følg instruksjonene.", "email_verified_message" : "E-posten ble verifisert", - "email_waiting_for_verification" : "E-post venter på verifisering: {email} ", + "email_waiting_for_verification" : "E-post venter på verifisering: {email}", "existing_language_not_selected" : "Språk ikke valgt", "expiration-custom-option" : "Tilpasset dato", "expiration-date-days-option" : "{days} dager", @@ -422,14 +423,14 @@ "expiration-label" : "Utløpsdato", "expiration-never-option" : "Utløper aldri", "expired_jwt_token" : "Du ble logget ut", - "export_translations_auto_publish_hint" : "Eksport vil bli utført automatisk etter at en oversettelse er endret eller lagt til. Siden innholdet leveres med tung caching, kan det ta opptil 15 minutter før endringene trer i kraft.", + "export_translations_auto_publish_hint" : "Eksport vil bli utført automatisk etter at en oversettelse er endret eller lagt til. Siden innholdsdistribusjonen er sterkt mellomlagret, kan det ta opptil 15 minutter før endringene trer i kraft.", "export_translations_auto_publish_label" : "Publiser automatisk", "export_translations_export_label" : "Eksporter", "export_translations_format_label" : "Format", "export_translations_languages_label" : "Språk", "export_translations_namespaces_all" : "Alle", - "export_translations_namespaces_label" : "Namespace-er", - "export_translations_nested_hint" : "Oversettelser vil bli gruppert basert på \".\" separator i nøklene", + "export_translations_namespaces_label" : "Navnerom", + "export_translations_nested_hint" : "Oversettelser vil bli gruppert basert på punktum-separator i nøklene", "export_translations_nested_label" : "Nestet struktur", "export_translations_states_label" : "Stater", "export_translations_title" : "Eksporter oversettelser", @@ -446,13 +447,13 @@ "file_issue_type_id_attribute_not_provided" : "Id-attributt for oversettelse ikke angitt", "file_issue_type_key_is_empty" : "Nøkkelen er tom", "file_issue_type_key_is_not_string" : "Nøkkelen er ikke en string", - "file_issue_type_po_msgctxt_not_supported" : "\"msgctxt\" verdien støttes ikke", + "file_issue_type_po_msgctxt_not_supported" : "\"msgctxt\"-verdi støttes ikke", "file_issue_type_target_not_provided" : "Måloversettelse ikke angitt", - "file_issue_type_translation_too_long" : "Oversettelsesteksten er for lang", + "file_issue_type_translation_too_long" : "Oversettelsestekst for lang", "file_issue_type_value_is_empty" : "Verdien er tom", - "file_issue_type_value_is_not_string" : "Verdien er ikke en string", + "file_issue_type_value_is_not_string" : "Verdien er ikke en streng", "file_too_big" : "Filen er for stor", - "formality_not_supported_by_service" : "Tjenesten støtter ikke formellitet", + "formality_not_supported_by_service" : "Tjenesten støtter ikke formell tone", "former-user-name" : "Tidligere bruker", "free_self_hosted_seat_limit_exceeded" : "Grensen for gratis brukerlisenser på denne selvhostede instansen er overskredet. Vennligst oppgrader abonnementsplanen din for å legge til flere brukere.", "generate_api_key_title" : "Generer prosjekt-API-nøkkel", @@ -467,15 +468,15 @@ "global_form_cancel" : "Avbryt", "global_form_field_required" : "Dette feltet er påkrevd", "global_form_save" : "Lagre", - "global_language_base" : "Utgangspunkt", + "global_language_base" : "Base", "global_loading_text" : "Laster inn...", "global_load_more" : "Last inn mer", "global_mt_credits_hint" : "Kreditter for maskinoversettelse", "global_nothing_found" : "Fant ingenting", - "global_search_organization" : "Søk i organisasjonen", - "global_strings_hint" : "Strengen er en fylt oversettelse. Fullstendig oversatt prosjekt vil ha (antall nøkler × antall språk) strenger.", - "global-upload-not-successful" : "Filen ble ikke opplastet", - "guide_finish_button" : "Fullfør guide", + "global_search_organization" : "Søk etter organisasjon", + "global_strings_hint" : "Strengen er en fylt oversettelse. Et fullstendig oversatt prosjekt vil ha (antall nøkler × antall språk) strenger.", + "global-upload-not-successful" : "Filen ble ikke lastet opp", + "guide_finish_button" : "Fullfør veiledning", "guide_finish_text" : "Gratulerer, du har fullført alle trinnene!", "guide_keys" : "Legg til nøkler manuelt eller bruk import", "guide_keys_add" : "Legg til nøkler\n", @@ -484,19 +485,19 @@ "guide_languages_set_up" : "Sett opp", "guide_links_docs_platform" : "Dokumentasjon", "guide_links_skip" : "Hopp over veiledning", - "guide_members" : "Inviter medlemmer til laget ditt", + "guide_members" : "Inviter medlemmer til teamet ditt", "guide_members_invite" : "Inviter", "guide_new_project" : "Start ditt første prosjekt", "guide_new_project_create" : "Opprett prosjekt", "guide_new_project_demo" : "Prøv demo", "guide_production" : "Forbered deg for produksjon", "guide_production_content_delivery" : "Innholdlevering", - "guide_title" : "Hurtigstart", + "guide_title" : "Rask start", "guide_try_demo_project" : "Prøv demo-prosjekt", "guide_use" : "Integrer Tolgee i din applikasjon", "guide_use_export" : "Eksporter", "guide_use_integrate" : "Integrer", - "hard_mode_confirmation_rewrite_text" : "Skriv om tekst: {text}", + "hard_mode_confirmation_rewrite_text" : "Skriv om teksten: {text}", "import-add-files-operation" : "Laster opp filer...", "import_add_new_language_dialog_title" : "Legg til nytt språk", "import_apply_button" : "Importer", @@ -507,8 +508,8 @@ "import_cannot_parse_file_message" : "Filen {name} kan ikke parses.", "import_conflicts_filter_show_resolved_label" : "Vis løste", "import_convert_placeholders_to_icu_checkbox_label" : "Konverter plassholdere til Tolgee Universal ICU", - "import_convert_placeholders_to_icu_checkbox_label_hint" : "Når aktivert, konverterer vi dine plassholdere til Tolgee ICU Universal-format som er nativt for Tolgee. Du kan eksportere Tolgee ICU Universal-plassholdere til ulike andre formater. Les mer ved å klikke på denne hjelp-ikonet.", - "import-data-imported-info" : "Dataene dine er importert!", + "import_convert_placeholders_to_icu_checkbox_label_hint" : "Når dette er aktivert, konverterer vi plassholderne dine til Tolgee ICU Universal-format som er nativt for Tolgee. Du kan eksportere Tolgee ICU Universal-plassholdere til ulike andre formater. Les mer ved å klikke på denne hjelp-ikonet.", + "import-data-imported-info" : "Dine data er importert!", "import_delete_language_dialog_message" : "Vil du virkelig fjerne språket {languageName} for import?", "import_delete_language_dialog_title" : "Fjern språk for import", "import-done-go-import-more-button" : "Importer mer", @@ -522,8 +523,8 @@ "import_file_input_select_file_button" : "Velg fil", "import_file_issue_param_type_file_node_original" : "Fil: {value}", "import_file_issue_param_type_key_id" : "Nøkkel-ID: {value}", - "import_file_issue_param_type_key_index" : "nøkkelindeks: {value}", - "import_file_issue_param_type_key_name" : "nøkkelnavn: {value}", + "import_file_issue_param_type_key_index" : "Nøkkelindeks: {value}", + "import_file_issue_param_type_key_name" : "Nøkkelnavn: {value}", "import_file_issue_param_type_language_id" : "Språk-ID: {value}", "import_file_issue_param_type_language_name" : "Språknavn: {value}", "import_file_issue_param_type_line" : "linje: {value}", @@ -531,14 +532,15 @@ "import_file_issues_title" : "Filproblemer", "import_files_uploaded" : "Filer lastet opp", "import_file_supported_formats" : "støttede formater er .json, .xliff, .po", + "import_file_supported_formats_title" : "Støttede formater", "import_language_select" : "Språk", "import_max_file_count_message" : "For mange filer", - "import_namespace_name_header" : "Namespace", + "import_namespace_name_header" : "Navnerom", "import_not_resolved_error_dialog_cancel_button" : "Tilbake", "import_not_resolved_error_dialog_message_text" : "Noen konflikter ble ikke løst. Vil du erstatte eksisterende oversettelser?", "import_not_resolved_error_dialog_title" : "Konflikter ikke løst", "import_override_key_descriptions_label" : "Tilbakestill nøkkelbeskrivelser", - "import_override_key_descriptions_label_hint" : "Når aktivert, vil nøkkelbeskrivelser bli erstattet av de som er oppgitt i de importerte filene.", + "import_override_key_descriptions_label_hint" : "Når aktivert, vil nøkkelbeskrivelser bli erstattet med de som er oppgitt i de importerte filene.", "import_resolution_accept_imported" : "Godta importert", "import_resolution_accept_old" : "Godta gammel", "import_resolve_conflicts_button" : "Løs konflikter", @@ -559,16 +561,16 @@ "import-status-storing-translations" : "Lagrer oversettelser...", "import-successful-message" : "Import vellykket", "import_translations_title" : "Importer oversettelser", - "integrate-api-key-hidden-description" : "", + "integrate-api-key-hidden-description" : "", "integrate_choose_your_weapon" : "Velg ditt våpen", - "integrate_guides_go_to_docs" : "Gå til Dokumenter", - "integrate-initial-api-key-description-value" : "Min integrasjon API-nøkkel", + "integrate_guides_go_to_docs" : "Gå til dokumentasjon", + "integrate-initial-api-key-description-value" : "Min integrasjons-API-nøkkel", "integrate_step_integrate" : "Integrer", "integrate_step_select_api_key" : "Velg API-nøkkel", "invalid_connection_string" : "Ugyldig tilkoblingsstreng", "invalid_language_tag" : "Denne språktaggen følger ikke BCP 47-standard. Vennligst oppgi en gyldig tagg.", "invalid_otp_code" : "Ugyldig 2FA-kode", - "invalid_plural_form" : "ICU flertallsform er ugyldig", + "invalid_plural_form" : "Ugyldig ICU-flertallsform", "invalid_recaptcha_token" : "Du er en robot!", "invitation_code_accepted" : "Invitasjonen ble akseptert vellykket", "invitation_code_does_not_exist_or_expired" : "Denne invitasjonen eksisterer ikke eller har utløpt", @@ -579,20 +581,20 @@ "invite_type_link" : "Lenke", "invite_user_invitation_cancel_button" : "Avbryt", "invite_user_invitation_copy_button" : "Kopier lenke", - "invite_user_invitation_copy_success" : "Invitasjonslenken ble kopiert til utklippstavlen", + "invite_user_invitation_copy_success" : "Lenke for invitasjon er kopiert til utklippstavlen", "invite_user_invitation_email_success" : "Invitasjonen ble sendt", "invite_user_nothing_found" : "Ingen ventende invitasjoner", - "invite_user_organization_role_label" : "Den inviterte brukeren vil være", + "invite_user_organization_role_label" : "Den inviterte brukeren vil bli", "invoice_usage_dialog_table_applied_stripe_credits_item" : "Brukte kreditter", "invoice_usage_dialog_table_count" : "Antall", "invoice_usage_dialog_table_item" : "Element", "invoice_usage_dialog_table_mt_credits_item" : "MT-kreditter", "invoice_usage_dialog_table_no_value" : "-", - "invoice_usage_dialog_table_over_plan" : "Over planen", + "invoice_usage_dialog_table_over_plan" : "Over plan", "invoice_usage_dialog_table_seats_item" : "Seter", "invoice_usage_dialog_table_subscription_item" : "Abonnement", "invoice_usage_dialog_table_subtotal" : "Delsum", - "invoice_usage_dialog_table_total" : "Total", + "invoice_usage_dialog_table_total" : "Totalt", "invoice_usage_dialog_table_translations_item" : "Oversettelser", "invoice_usage_dialog_table_vat" : "MVA", "invoice_usage_dialog_table_vat_rate" : "MVA-sats", @@ -613,7 +615,7 @@ "language_ai_prompt_dialog_description_too_long" : "Beskrivelsen er for lang", "language_ai_prompt_dialog_title" : "Språknotat", "language_ai_prompt_row_label" : "Språknotat", - "language_ai_prompts_row_hint" : "En notat for AI-oversetter spesifikt for målspråket", + "language_ai_prompts_row_hint" : "Et notat for AI-oversetteren spesifikt for målspråket", "language_ai_prompts_title" : "Notater for enkelte språk", "language_ai_prompt_tip_language" : "Skriv på engelsk eller på målspråket", "language_ai_prompt_tip_usage" : "Notatet er spesifikt for målspråket", @@ -639,7 +641,7 @@ "languages_permitted_list_reset" : "Tilbakestill", "languages_permitted_list_select_all" : "Alle språk", "languages_title" : "Språk", - "language_tag_exists" : "Tag finnes allerede", + "language_tag_exists" : "Tagg eksisterer allerede", "leave_project_confirmation_message" : "Er du sikker på at du vil forlate dette prosjektet?", "leave_project_confirmation_title" : "Forlat prosjektet", "license_key_not_found" : "Ugyldig lisensnøkkel", @@ -657,7 +659,7 @@ "login_sign_up" : "Registrer", "login_title" : "Logg Inn", "login_tolgee_documentation_link" : "Lær mer i dokumentasjonen", - "login_tolgee_website_link" : "Sjekk ut våre kule funksjoner på Tolgee nettside", + "login_tolgee_website_link" : "Sjekk våre kule funksjoner på Tolgee nettside", "machine_translation_buy_more_credit" : "Kjøp flere kreditter", "machine_translation_new_keys_title" : "Automatisk oversettelse av nye nøkler", "machine_translation_title" : "Maskinoversettelse", @@ -672,37 +674,28 @@ "namespace_default" : "", "namespace_menu_filter" : "Filtrer etter navnerom", "namespace_menu_filter_cancel" : "Fjern filter", - "namespace_menu_rename" : "Gi nytt navn til namespace-et", + "namespace_menu_rename" : "Gi nytt navn til navnerommet", "namespace_rename_cancel" : "Avbryt", - "namespace_rename_confirm" : "Gi nytt navn", - "namespace_rename_confirmation_message" : "Vil du virkelig endre navn på namespace-et? Sørg for at du gjenspeiler denne endringen i den oversatte applikasjonen.", + "namespace_rename_confirm" : "Endre navn", + "namespace_rename_confirmation_message" : "Ønsker du virkelig å endre navneromnavnet? Sørg for å gjenspeile denne endringen i oversatt applikasjon.", "namespace_rename_confirmation_title" : "Er du sikker?", "namespace_rename_placeholder" : "Nytt navn...", - "namespace_rename_success" : "Namespace-et har fått nytt navn", - "namespace_rename_title" : "Gi nytt navn til namespace-et", + "namespace_rename_success" : "Navnerom omdøpt", + "namespace_rename_title" : "Gi nytt navn til navnerommet", "namespace_select_cancel" : "Avbryt", "namespace_select_confirm" : "Ok", "namespace_select_default" : "", "namespace_select_new" : "Legg til ny", - "namespace_select_placeholder" : "Sett inn nytt namespace navn", - "namespace_select_search" : "Søk i namespace-et...", - "namespae_select_title" : "Nytt namespace", + "namespace_select_placeholder" : "Sett inn nytt navneromnavn", + "namespace_select_search" : "Søk etter navnerom...", + "namespae_select_title" : "Nytt navnerom", "new-password-input-label" : "Nytt passord", "no_exported_result" : "Ingenting å eksportere", "no-permissions-on-the-server" : "Du har ingen tillatelse på denne serveren. Denne serveren tillater ikke brukere å opprette organisasjoner.", - "no-permissions-title" : "Ingen tilgang", + "no-permissions-title" : "Ingen tillatelse", "no_screenshots_yet" : "Ingen skjermbilder er lagt til ennå.", "operation_not_permitted" : "Dine tillatelser er ikke tilstrekkelige for denne operasjonen.", "operation_not_permitted_error" : "Dine tillatelser er ikke tilstrekkelige for denne operasjonen.", - "order_translation_dialog_cancel" : "Avbryt", - "order_translation_dialog_consent" : "Jeg forstår at mine kontaktdetaljer (e-post) vil bli delt med en tredjepartsleverandør og jeg gir leverandøren tillatelse til å kontakte meg.", - "order_translation_dialog_note_label" : "Kommentar", - "order_translation_dialog_note_placeholder" : "Vennligst gi ytterligere kontekst om prosjektet ditt", - "order_translation_dialog_projects_label" : "Velg prosjekt(er)", - "order_translation_dialog_send_invitation" : "Jeg vil invitere {provider} som medlem til de valgte prosjektene.", - "order_translation_dialog_submit" : "Send", - "order_translation_dialog_subtitle" : "Velg prosjektene du ønsker å få et kostnadsestimat for. Tolgee vil dele ordtellingen og språkene med leverandøren. Leverandøren vil deretter sende deg et estimat via e-post.", - "order_translation_dialog_title" : "Få tilbud fra {provider}", "organization_already_subscribed" : "Organisasjonen er allerede abonnert på en plan.", "organization-billing-self-hosted-active-subscriptions" : "Aktive abonnementer", "organization-billing-self-hosted-cancel-subscription-button" : "Avbryt", @@ -710,12 +703,12 @@ "organization-billing-self-hosted-subscription-cancel-confirmation" : "Vil du virkelig avbryte abonnementet ditt?", "organization-billing-self-hosted-subscription-cancelled-message" : "Abonnement kansellert", "organization_created_message" : "Organisasjon opprettet", - "organization_delete_button" : "Slett organisasjonen", + "organization_delete_button" : "Slett organisasjon", "organization_deleted_message" : "Organisasjon slettet", - "organization_has_no_other_owner" : "Organisasjonen har ingen andre eiere.", + "organization_has_no_other_owner" : "Organisasjonen har ingen annen eier.", "organization_invoices_title" : "Fakturaer", - "organization_leave_button" : "Forlat organisasjonenen", - "organization_left_message" : "Du forlot organisasjonen", + "organization_leave_button" : "Forlat organisasjon", + "organization_left_message" : "Organisasjon forlatt", "organization_member_privileges_set" : "Tillatelser satt", "organization_member_privileges_text" : "Alle organisasjonsbrukere har disse tillatelsene for hvert prosjekt i denne organisasjonen.", "organization_member_privileges_title" : "Organisasjonsmedlemstillatelser", @@ -748,8 +741,8 @@ "organization_users_projects_title" : "Brukerprosjekter", "organization_users_remove_user" : "Fjern", "organization_your_address_to_access_organization" : "Dette vil være URL-en til organisasjonen din: {address}", - "out_of_credits" : "Tom for kreditter for maskinoversettelse", - "paid-feature-banner-title" : "Din plan inkluderer ikke denne funksjonen.", + "out_of_credits" : "Ingen flere kreditter for maskinoversettelse", + "paid-feature-banner-title" : "Ditt abonnement inkluderer ikke denne funksjonen.", "parser_duplicate_plural_argument_selector" : "Dupliser alternativ", "parser_duplicate_select_argument_selector" : "Dupliser alternativ", "parser_empty_argument" : "Tom parameter", @@ -757,27 +750,27 @@ "parser_expect_argument_style" : "Forventer parameter stil", "parser_expect_argument_type" : "Forventer parameter type", "parser_expect_date_time_skeleton" : "Forventer tids-skjelett", - "parser_expect_number_skeleton" : "Forventer tallskjelett", + "parser_expect_number_skeleton" : "Forventer tall-skjelett", "parser_expect_plural_argument_offset_value" : "Forventer forskyvningsverdi", "parser_expect_plural_argument_selector" : "Forventer alternativer", "parser_expect_plural_argument_selector_fragment" : "Mangler verdi for alternativet", "parser_expect_select_argument_options" : "Forventer alternativer", "parser_expect_select_argument_selector" : "Forventer alternativer", "parser_expect_select_argument_selector_fragment" : "Mangler verdi for alternativet", - "parser_invalid_argument_type" : "Ugyldig parametertype", - "parser_invalid_date_time_skeleton" : "Ugyldig dato- eller tidsformat", + "parser_invalid_argument_type" : "Ugyldig parameter type", + "parser_invalid_date_time_skeleton" : "Ugyldig dato- og tidsramme", "parser_invalid_number_skeleton" : "Ugyldig tallskjelett", "parser_invalid_plural_argument_offset_value" : "Ugyldig offset-verdi", "parser_invalid_plural_argument_selector" : "Ugyldig parameterformat", "parser_invalid_tag" : "Ugyldig tagg", "parser_invalid_tag_name" : "Ugyldig tagnavn", "parser_malformed_argument" : "Parameter i feil format", - "parser_missing_other_clause" : "Manglende obligatorisk valg \"other\"", - "parser_unclosed_quote_in_argument_style" : "Sitat-tegn ikke avsluttet", + "parser_missing_other_clause" : "Manglende obligatorisk valgmulighet \"other\"", + "parser_unclosed_quote_in_argument_style" : "Mangler avsluttende anførselstegn", "parser_unclosed_tag" : "Mangler avsluttende tagg", "parser_unmatched_closing_tag" : "Mangler avsluttende tagg", "Password" : "Passord", - "Password confirmation" : "Bekreft passord", + "Password confirmation" : "Passordbekreftelse", "password_reset_message" : "Passordet er tilbakestilt", "password-strength-medium" : "Middels passord", "password-strength-strong" : "Sterkt passord", @@ -801,7 +794,7 @@ "pat_never_expires" : "Utløper aldri", "pat-new-token-message" : "Nøkkel opprettet. Sørg for å kopiere din personlige tilgangsnøkkel nå. Du vil ikke kunne se den igjen!", "pat-regenerated-token-message" : "Nøkkel regenerert. Sørg for å kopiere din personlige tilgangsnøkkel nå. Du vil ikke kunne se den igjen!", - "pats-description" : "Personlige tilgangstokens er nyttige når du trenger å jobbe med flere prosjekter, organisasjoner eller når du trenger å jobbe med ressurser som ikke er tilgjengelige ved hjelp av prosjekt-API-nøkler. Hvis du vil skaffe en nøkkel for bruk med Tolgee-integrasjoner, bruk prosjekt-API-nøkler.", + "pats-description" : "Personlige tilgangsnøkler er nyttige når du trenger å jobbe med flere prosjekter, organisasjoner eller når du trenger å jobbe med ressurser som ikke er tilgjengelige ved hjelp av prosjekt-API-nøkler. Hvis du vil skaffe en nøkkel for bruk med Tolgee-integrasjoner, bruk prosjekt-API-nøkler.", "pats-empty-action" : "Opprett ny token", "pats-empty-message" : "Ingen personlige tilgangsnøkler er lagt til ennå.", "pats_title" : "Personlige tilgangsnøkler", @@ -858,7 +851,7 @@ "permission_type_manage_hint" : "Administrasjon av prosjektinnstillinger, import, endringer av oversettelser og nøkler", "permission_type_none" : "Ingen", "permission_type_none_hint" : "Brukeren har ingen prosjekttilgang", - "permission_type_review" : "Gjennomgang", + "permission_type_review" : "Gjennomgå", "permission_type_review_hint" : "Endringer av oversettelser og gjennomgang", "permission_type_translate" : "Oversett", "permission_type_translate_hint" : "Endringer av oversettelser", @@ -869,7 +862,7 @@ "plan_limit_dialog_description" : "Handlingen kan ikke utføres fordi det vil overstige grensen for gjeldende plan", "plan_limit_dialog_go_to_billing" : "Oppgrader planen", "plan_limit_dialog_title" : "Grensen er overskredet", - "plan_translation_limit_exceeded" : "Grensen for planoversettelse er overskredet", + "plan_translation_limit_exceeded" : "Oversettelsesgrensen for planen er overskredet", "project_ai_prompt_add" : "Beskrivelse", "project_ai_prompt_dialog_description_too_long" : "Beskrivelsen er for lang", "project_ai_prompt_dialog_tip_language" : "Bruk engelsk for beskrivelsen", @@ -883,7 +876,7 @@ "project_create_tolgee_placeholders_hint" : "Tolgee universelle plassholdere er en delmengde av ICU-plassholdere, som automatisk blir transformert til målformater ved eksport", "project_create_use_tolgee_placeholders_label" : "Bruk Tolgee Universal ICU-plassholdere", "project_creation_add_at_least_one_language" : "Legg til minst ett språk", - "project_dashboard_base_words_count" : "Utgangspunkt ord", + "project_dashboard_base_words_count" : "Grunnord", "project_dashboard_chart_daily_activity_count_tooltip" : "Daglig aktivitet", "project_dashboard_key_count" : "Nøkler", "project_dashboard_language_count" : "Språk", @@ -892,7 +885,7 @@ "project_dashboard_member_count" : "Medlemmer", "project_dashboard_percent_count" : "{percentage, number, :: % }", "project_dashboard_percent_nan" : "-", - "project_dashboard_project_id" : "ID {id, number}", + "project_dashboard_project_id" : "ID {id, nummer}", "project_dashboard_project_owner" : "Prosjekteier", "project_dashboard_reviewed_percent" : "Gjennomgått", "project_dashboard_show_translations" : "Vis oversettelser", @@ -903,7 +896,7 @@ "project_dashboard_translations_less_then_1_percent" : "< 1 %", "project_dashboard_translations_percent" : "{percent, number, :: %}", "project_deleted_message" : "Prosjektet er slettet!", - "project_integrate_description" : "Bruk Tolgee-integrasjoner for å øke hastigheten på lokaliseringen. Javascript-integrasjoner lar deg redigere oversettelser direkte i applikasjonen din. Dette forbedrer også kvaliteten på oversettelsene, siden vi samler inn konteksten på siden og deretter gir den til oversetterne dine eller vår AI-maskinoversetter. Mer om Tolgee-integrasjoner.", + "project_integrate_description" : "Bruk Tolgee-integrasjoner for å fremskynde lokaliseringen. Javascript-integrasjoner lar deg redigere oversettelser direkte i applikasjonen din. Dette forbedrer også kvaliteten på oversettelsene, siden vi samler konteksten på siden og deretter gir den til oversetterne dine eller vår AI-maskinoversetter. Mer om Tolgee-integrasjoner.", "project_integrate_title" : "Integrer", "project_languages_add_button" : "Språk", "project_languages_auto_translation_enable_for_import_switch" : "Automatisk oversett importerte elementer", @@ -941,32 +934,29 @@ "project_menu_integrate" : "Integrer", "project_menu_languages" : "Språk", "project_menu_members" : "Medlemmer", - "project_menu_order_translation" : "Bestill oversettelsestjeneste", "project_menu_projects" : "Prosjekter", "project_menu_project_settings" : "Prosjektinnstillinger", "project_menu_translations" : "Oversettelser", "project_mt_dialog_cancel_button" : "Avbryt", - "project_mt_dialog_reset_to_default" : "Tilbakestill til standardverdi", + "project_mt_dialog_reset_to_default" : "Tilbakestill til standard", "project_mt_dialog_save_button" : "Lagre", "project_mt_dialog_service_enabled" : "Aktivert", "project_mt_dialog_service_formality" : "Formalitet", "project_mt_dialog_service_not_supported" : "Tilbyderen støtter ikke dette språket", "project_mt_dialog_service_primary" : "Hoved", "project_mt_dialog_service_suggested" : "Foreslått", - "project_mt_dialog_service_suggested_hint" : "Tjenester som vises i forslagene i oversettelsespanelet", + "project_mt_dialog_service_suggested_hint" : "Tjenester som vises i forslagene på oversettelsespanelet", "project_mt_dialog_settings_inherited_message" : "Arvet fra standardinnstillinger", "project_mt_dialog_title" : "Innstillinger for maskinoversettelse", - "project_order_translation_subtitle" : "Bestill oversettelse fra profesjonelle oversettere", - "project_order_translation_title" : "Bestill oversettelsestjeneste", "project_permission_information_text_base_permission_after" : "Dette betyr at hver bruker har minst denne tillatelsen. Du kan ikke sette den lavere enn det.", "project_permission_information_text_base_permission_before" : "Dette prosjektet er en del av en organisasjon som har grunnleggende tillatelser satt til:", - "project_permissions_revoke_user_access_message" : "Vil du virkelig tilbakekalle tilgangen for brukeren {userName}?", + "project_permissions_revoke_user_access_message" : "Ønsker du virkelig å tilbakekalle tilgangen for brukeren {userName}?", "projects_add_button" : "Legg til prosjekt", "projects_empty" : "Ingen prosjekter", "projects_empty_action" : "Legg til prosjekt", "project_settings_base_language" : "Grunnspråk", "project_settings_button" : "Innstillinger", - "project_settings_danger_zone_title" : "Faresone", + "project_settings_danger_zone_title" : "Fareområde", "project_settings_description_label" : "Beskrivelse (markdown)", "project_settings_development_title" : "Utvikling", "project_settings_menu_advanced" : "Avansert", @@ -977,34 +967,34 @@ "project_settings_use_tolgee_placeholders_label" : "Bruk Tolgee Universal ICU-plassholdere", "projects_search_placeholder" : "Søk etter prosjekt...", "projects_title" : "Prosjekter", - "project_successfully_edited_message" : "Prosjektinnstillingene ble lagret.", - "project_successfully_left" : "Du forlot prosjektet.", + "project_successfully_edited_message" : "Prosjektinnstillinger er lagret.", + "project_successfully_left" : "Prosjekt forlatt.", "project_transfer_autocomplete_label" : "Velg ny eier", "project_transferred_message" : "Prosjekt overført", - "project_transfer_rewrite_project_name_to_confirm_message" : "For å bekrefte denne handlingen, skriv inn prosjektnavnet.", + "project_transfer_rewrite_project_name_to_confirm_message" : "Skriv inn prosjektnavnet for å bekrefte handlingen.", "quick_start_highlight_ok" : "Ok", "quick_start_highlight_skip" : "Hopp over tips", "quick_start_item_add_language_hint" : "Legg til eller endre prosjektspråk", "quick_start_item_automatic_translation_hint" : "Oversett nøkler automatisk når de opprettes eller endres!", "quick_start_item_export_form_hint" : "Eksporter statiske filer og bruk dem direkte i applikasjonen din", - "quick_start_item_integrate_form_hint" : "Koble appen din direkte til Tolgee og få unik funksjon for redigering i kontekst og mye mer!", + "quick_start_item_integrate_form_hint" : "Koble appen din direkte til Tolgee og få unik in-context redigeringsfunksjon og mye mer!", "quick_start_item_invitations_hint" : "Inviter nye brukere eller administrer eksisterende invitasjoner", - "quick_start_item_machine_translation_hint" : "Konfigurer maskinoversettelsestjenester eller autonom oversettelse", + "quick_start_item_machine_translation_hint" : "Sett opp maskinoversettelsestjenester eller autonom oversettelse", "quick_start_item_members_hint" : "Administrer eksisterende medlemmer og deres tillatelser", "quick_start_item_pick_import_file_hint" : "Last opp dine eksisterende oversettelsesfiler til plattformen", - "quick_start_translation_namespace_input_hint" : "Namespace gjør det mulig å separere oversettelser i separate oversettelsesfiler. De er nyttige for større prosjekter.", + "quick_start_translation_namespace_input_hint" : "Navnerom tillater å separere oversettelser i separate oversettelsesfiler. De er nyttige for større prosjekter.", "really_leave_organization_confirmation_message" : "Er du sikker på at du vil forlate organisasjonen?", "really_remove_user_confirmation" : "Er du sikker på at du vil fjerne brukeren {userName} fra organisasjonen?", "really_want_to_change_base_permission_confirmation" : "Ønsker du virkelig å endre grunnleggende tillatelser for denne organisasjonen?", - "really_want_to_change_role_confirmation" : "Vil du virkelig endre rollen?", - "regenerate_api_key_title" : "Generer nytt prosjekt-API-nøkkel", + "really_want_to_change_role_confirmation" : "Ønsker du virkelig å endre rollen?", + "regenerate_api_key_title" : "Regenerer prosjekt-API-nøkkel", "regenerate_pat_title" : "Regenerer token", "registrations_not_allowed" : "Registreringer er ikke aktivert", "request_parse_error" : "Intern feil oppstod", "reset_password_email_field" : "E-post", "reset_password_send_request_button" : "Send forespørsel", "reset_password_set_title" : "Nytt passord", - "reset_password_success_message" : "Forespørsel sendt! Hvis du er registrert med denne e-posten, vil du motta en e-post med en lenke for tilbakestilling av passordet. Sjekk e-posten din.", + "reset_password_success_message" : "Forespørsel sendt! Hvis du er registrert med denne e-posten, vil du motta en e-post med en lenke for tilbakestilling av passordet. Sjekk innboksen din.", "reset_password_title" : "Tilbakestill passord", "resource_not_found" : "Ikke funnet", "resource_not_found_message" : "Ikke funnet", @@ -1014,7 +1004,7 @@ "screenshot_delete_title" : "Slett skjermbilde", "seats_spending_limit_exceeded" : "Grensen for seteutgifter er overskredet", "sensitive-authentication-dialog-title" : "Autentisering", - "sensitive-authentication-message" : "For å fortsette med denne handlingen, vennligst autentiser på nytt.", + "sensitive-authentication-message" : "For å fortsette med denne operasjonen, vennligst autentiser deg på nytt", "sensitive-auth-submit-button" : "OK", "sensitive-dialog-provide-2fa-code" : "Oppgi en kode fra din tofaktorautentiseringsapp.", "set_at_least_one_language_error" : "Velg minst ett språk.", @@ -1025,10 +1015,10 @@ "sign_up_submit_button" : "Send", "sign_up_success_message" : "Takk for at du registrerte deg!", "sign_up_success_needs_verification_message" : "Takk for at du registrerte deg. For å verifisere e-posten din, følg instruksjonene som er sendt til den oppgitte e-postadressen.", - "sign-up-terms-and-conditions-message" : "Ved å klikke på Send, godtar jeg at jeg har lest og akseptert Tolgee \n \n Bruksvilkår\n ", + "sign-up-terms-and-conditions-message" : "Ved å klikke på Send, godtar jeg at jeg har lest og akseptert Tolgee Brukervilkår", "sign_up_title" : "Registrer", "simple_paginated_list_error_message" : "Kan ikke laste inn data", - "slug_validation_can_contain_just_lowercase_numbers_hyphens" : "Dette feltet kan kun inneholde små bokstaver, tall og bindestreker", + "slug_validation_can_contain_just_lowercase_numbers_hyphens" : "Dette feltet kan kun inneholde små bokstaver, tall og bindestrek", "spending_limit_dialog_close" : "Lukk", "spending_limit_dialog_description" : "Kontakt støtte på billing@tolgee.io for å øke din spendinggrense", "spending_limit_dialog_title" : "Grensen for utgifter er overskredet", @@ -1050,7 +1040,7 @@ "storage_item_default" : "Standard", "storage_item_delete_dialog_title" : "Slett innholdslagring", "storage_item_edit" : "Rediger", - "storage_not_enabled_message" : "Ditt abonnement inkluderer ikke tilpassede innholdslagring. Oppgrader abonnementet ditt for å få tilgang til denne funksjonen.", + "storage_not_enabled_message" : "Ditt abonnement inkluderer ikke egendefinerte innholdslagring. Oppgrader abonnementet ditt for å få tilgang til denne funksjonen.", "storage_over_limit_title" : "Du har overskredet grensen, endring er begrenset", "storage_subtitle" : "Prosjektinnholdslagring", "storage_update_success" : "Innholdslagring oppdatert!", @@ -1065,7 +1055,7 @@ "this_will_delete_organization_forever" : "Denne handlingen vil slette organisasjonen permanent.", "this_will_delete_project_forever" : "Denne handlingen vil slette prosjektet permanent.", "this_will_transfer_project" : "Dette vil overføre prosjektet til en annen eier.", - "token-regenerate-message" : "Den nåværende token vil bli erstattet. Du vil ikke lenger kunne bruke den.", + "token-regenerate-message" : "Den nåværende nøkkelen vil bli erstattet. Du vil ikke lenger kunne bruke den.", "tools_panel_hint" : "Hint", "tranfer_project_dialog_warning" : "Dette vil overføre prosjektet til en annen eier.", "transfer_option_organization" : "organisasjon", @@ -1080,17 +1070,17 @@ "translation_failed" : "Oversettelse mislyktes", "translation_grid_key_text" : "Nøkkel", "Translation grid - Successfully deleted!" : "Oversettelser slettet!", - "translation_provider_get_quote" : "Få kostnadsestimat", "translations_auto_translated_provider" : "Oversatt automatisk med {provider}", "translations_auto_translated_tm" : "Oversatt automatisk med oversettelseshukommelse", "translations_cell_cancel" : "Avbryt", "translations_cell_change_state" : "Endre tilstand", + "translations_cell_close" : "Lukk", "translations_cell_edit" : "Rediger", "translations_cell_insert_base" : "Sett inn grunntekst", "translations_cell_outdated" : "Utdatert (grunnteksten har endret seg)", "translations_cell_save" : "Lagre", - "translations_cell_save_and_continue" : "Lagre & fortsett", - "translations_cell_tab_comments" : "{count, plural, =0 {Kommentarer} other {Kommentarer (#)}}", + "translations_cell_save_and_continue" : "Lagre og fortsett", + "translations_cell_tab_comments" : "{count, plural, =0 {Kommentarer} one {Kommentar} other {Kommentarer (#)}}", "translations_cell_tab_edit" : "Rediger", "translations_cell_tab_history" : "Historikk", "translations_clear_selection" : "Fjern valg", @@ -1103,13 +1093,13 @@ "translations_comments_resolved" : "Løst", "translations_delete_selected" : "Slett valgte", "translations_discard_button_confirm" : "Forkast", - "translations_discard_unsaved_message" : "Du har ulagrede endringer, vil du forkaste dem?", + "translations_discard_unsaved_message" : "Har du ulagrede endringer, vil du forkaste dem?", "translations_discard_unsaved_title" : "Forkast endringer?", "translations_editor_switch_to_placeholders" : "Vis plassholdere", "translations_editor_switch_to_raw" : "Skjul plassholdere", - "translations_filter_placeholder" : "Filter...", + "translations_filter_placeholder" : "Filtrer...", "translations_filters_heading_clear" : "Tøm filter", - "translations_filters_heading_namespaces" : "Namespace-er", + "translations_filters_heading_namespaces" : "Navnerom", "translations_filters_heading_screenshots" : "Skjermbilder", "translations_filters_heading_states" : "Stater", "translations_filters_heading_tags" : "Tagger", @@ -1117,14 +1107,14 @@ "translations_filters_missing_translation" : "Mangler oversettelse", "translations_filters_no_screenshots" : "Ingen skjermbilder", "translations_filters_something_outdated" : "Utdatert oversettelse", - "translations_filters_text" : "{filtersNum, plural, one {1 filter} other {# filtre}", + "translations_filters_text" : "{filtersNum, plural, one {1 filter} other {# filtre}}", "translations_filters_with_screenshots" : "Med skjermbilder", "translations-history-differences-toggle" : "Forskjeller", "translations_history_load_more" : "Last inn mer", "translations_history_previous_items" : "Tidligere elementer", "translation_single_create_title" : "Opprett ny nøkkel", "translation_single_delete_success" : "Nøkkel slettet!", - "translation_single_delete_text" : "Er du sikker på at du vil slette nøkkelen med dens oversettelser?", + "translation_single_delete_text" : "Ønsker du virkelig å slette nøkkelen med dens oversettelser?", "translation_single_delete_title" : "Slett nøkkel", "translation_single_description_hint" : "Beskriv oversettelseskonteksten. Hvis du bruker Tolgee-translatør, brukes beskrivelsen til å forbedre AI-oversettelsen.", "translation_single_label_delete" : "Fjern nøkkel", @@ -1137,7 +1127,7 @@ "translation_single_label_screenshots" : "Skjermbilder", "translation_single_label_tags" : "Tagger", "translation_single_namespace_hint" : "Bruk navnerom for store prosjekter, der du ønsker å separere nøkler i flere filer", - "translation_single_no_permission_create" : "Du har utilstrekkelige tillatelser for å legge til ny nøkkel", + "translation_single_no_permission_create" : "Du har ikke tilstrekkelige tillatelser til å legge til ny nøkkel", "translation_single_tag_placeholder" : "Legg til tagg...", "translation_single_translations_title" : "Oversettelser", "translations_key_created" : "Nøkkel opprettet", @@ -1169,7 +1159,7 @@ "validation" : { "file_too_big" : "Filen {filename} er for stor.", "too_many_files" : "For mange filer.", - "unsupported_format" : "Filtypen {filename} støttes ikke." + "unsupported_format" : "Filtype {filename} støttes ikke." } } }, @@ -1180,7 +1170,6 @@ "translations_shortcuts_in_editor_title" : "I redigeringsverktøyet", "translations_shortcuts_in_list_title" : "I liste", "translations_shortcuts_move" : "Flytt", - "translations_shortcuts_title" : "Tastatursnarveier", "translations_tag_create" : "Legg til \"{tag}\"", "translations_tag_label" : "tagg", "translations_tags_no_results" : "Fant ingenting", @@ -1190,10 +1179,10 @@ "translation_state_translated" : "Oversatt", "translation_state_untranslated" : "Uoversatt", "translations_toolbar_to_top" : "Til toppen", - "translations_unsaved_changes_confirmation" : "Du har ulagrede endringer, vil du lagre dem?", + "translations_unsaved_changes_confirmation" : "Har du ulagrede endringer, vil du lagre dem?", "translations_unsaved_changes_confirmation_title" : "Ulagrede endringer", "translations_view_title" : "Oversettelser", - "translation_tools_base_empty" : "Basisoversettelse er tom", + "translation_tools_base_empty" : "Baseteksten er tom", "translation_tools_comments" : "Kommentarer", "translation_tools_history" : "Historikk", "translation_tools_keyboard_shortcuts" : "Tastatursnarveier", @@ -1218,14 +1207,14 @@ "user_is_part_of_organization_tooltip" : "Brukeren er medlem av organisasjonen som eier dette prosjektet.", "user_menu_api_keys" : "Prosjekt-API-nøkler", "user_menu_logout" : "Logg ut", - "user_menu_organization_settings" : "Innstillinger for organisasjonen", + "user_menu_organization_settings" : "Organisasjonsinnstillinger", "user_menu_organization_switch" : "Bytt organisasjon", "user_menu_pats" : "Personlige tilgangsnøkler", "user_menu_server_administration" : "Serveradministrasjon", "user_menu_user_settings" : "Kontoinnstillinger", "username_already_exists" : "Brukernavn eksisterer allerede.", "user-profile" : { - "edit-avatar" : "Sett avatar", + "edit-avatar" : "Sett profilbilde", "remove-avatar-menu-item" : "Fjern avatar", "upload-avatar-menu-item" : "Last opp nytt bilde" }, @@ -1256,14 +1245,14 @@ "webhooks_add_button" : "Webhook", "webhook_secret_description" : "Denne hemmeligheten brukes til å signere webhook-forespørselen, slik at du kan sikre sluttpunktet ditt på denne måten.", "webhook_secret_title" : "Webhook hemmelighet", - "webhooks_failing_hint" : "Webhook feilet i siste kjøring", + "webhooks_failing_hint" : "Webhook feilet i forrige kjøring", "webhooks_last_run_hint" : "Siste utførelsestidspunkt", - "webhooks_not_enabled_message" : "Ditt abonnement inkluderer ikke webhook-funksjonen. Oppgrader abonnementet ditt for å få tilgang til denne funksjonen.", + "webhooks_not_enabled_message" : "Ditt abonnement inkluderer ikke webhooks-funksjonen. Oppgrader abonnementet ditt for å få tilgang til denne funksjonen.", "webhooks_over_limit_title" : "Du har overskredet grensen, endring er begrenset", - "webhooks_subtitle" : "Prosjekt Webhooks", + "webhooks_subtitle" : "Prosjektwebhooks", "webhook_test_fail" : "Testen mislyktes!", "webhook_test_success" : "Testforespørsel sendt til webhooken vellykket", "webhook_update_title" : "Rediger webhook", "wrong_current_password" : "Feil nåværende passord oppgitt", - "your_email_was_changed_verification_message" : "Når du endrer e-posten din, vil den nye e-posten bli satt etter at den er verifisert." + "your_email_was_changed_verification_message" : "Når du endrer e-posten din, vil den nye e-posten bli satt etter verifisering." } \ No newline at end of file diff --git a/webapp/src/i18n/pt.json b/webapp/src/i18n/pt.json index 25df02ea05..34f844ef56 100644 --- a/webapp/src/i18n/pt.json +++ b/webapp/src/i18n/pt.json @@ -63,8 +63,10 @@ "activity_translation_history_modify" : "Tradução modificada", "add_screenshots_message" : "Adicione algo soltando ou clicando no mais.", "administration-access-message" : "Você está acessando esta página como administrador do servidor", + "administration_cloud_plan_create" : "Criar plano", "administration-debugging-customer-account-message" : "Você está depurando a conta do usuário.", "administration_delete_user_button" : "Excluir", + "administration_ee_plan_delete_message" : "Você realmente deseja excluir este plano de preços?", "administration-exit-debug-customer-account" : "Sair da depuração", "administration_organization_projects" : "Projetos", "administration_organizations" : "Organizações", diff --git a/webapp/src/i18n/ro.json b/webapp/src/i18n/ro.json index 3e74aee99e..9b0c896b4e 100644 --- a/webapp/src/i18n/ro.json +++ b/webapp/src/i18n/ro.json @@ -125,6 +125,7 @@ "administration_ee_plan_create" : "Creează plan", "administration_ee_plan_created_success" : "Plan creat", "administration_ee_plan_deleted_success" : "Planul a fost șters", + "administration_ee_plan_delete_message" : "Sigur doriți să ștergeți acest plan de prețuri?", "administration_ee_plan_edit" : "Editează planul", "administration_ee_plan_edit_button" : "Editare", "administration_ee_plan_field_free" : "Gratuit", @@ -167,6 +168,7 @@ "announcement_feature_mt_formality" : "Traducerile automate acum suportă formalitatea", "announcement_general_link_text" : "Afișează mai mult", "announcement_new_pricing" : "Tolgee introduce noi prețuri pentru instanțele auto-găzduite", + "announcement_visual_editor_and_formats_support" : "Lansare nouă editor vizual și suport pentru formate!", "api-key-delete-button" : "Ștergeți", "api-key-deleted-message" : "Cheia API a fost ștearsă", "api-key-delete-token-confirmation-message" : "Sigur doriți să ștergeți această cheie API?", @@ -273,7 +275,7 @@ "billing_plan_credits_included" : "Credite MT lunare", "billing-plan-monthly-price" : "{price, number, ::precision-integer currency/EUR}/lună", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/lună per fiecare loc suplimentar", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR} per fiecare 1000 de credite MT suplimentare", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR} per fiecare 1000 de credite MT suplimentare", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR}/lună per fiecare 1000 de șiruri suplimentare", "billing_plan_resubscribe" : "Restaurează abonamentul", "billing_plan_strings_included" : "Șiruri incluse", @@ -432,6 +434,8 @@ "export_translations_nested_hint" : "Traducerile vor fi înglobate în funcție de separatorul \".\" din chei", "export_translations_nested_label" : "Structură imbricată", "export_translations_states_label" : "State", + "export_translations_support_arrays_hint" : "Când este activat, cheile precum \"item[0]\" vor fi convertite în tablouri JSON.", + "export_translations_support_arrays_label" : "Suport pentru tablouri", "export_translations_title" : "Exportă traducerile", "feature-explanation-check-license-action" : "Afișează licența", "feature-explanation-license-not-active" : "Licența Tolgee nu este activă", @@ -531,6 +535,7 @@ "import_file_issues_title" : "Probleme fișier", "import_files_uploaded" : "Fișierele au fost încărcate", "import_file_supported_formats" : "Formatele suportate sunt .json, .xliff, .po", + "import_file_supported_formats_title" : "Formate suportate", "import_language_select" : "Limba", "import_max_file_count_message" : "Prea multe fișiere", "import_namespace_name_header" : "Spațiu de nume", @@ -659,6 +664,7 @@ "login_tolgee_documentation_link" : "Aflați mai multe în documentație", "login_tolgee_website_link" : "Verificați funcțiile noastre interesante pe site-ul Tolgee", "machine_translation_buy_more_credit" : "Cumpără mai multe credite", + "machine_translation_empty" : "Gol", "machine_translation_new_keys_title" : "Traducere automată a cheilor noi", "machine_translation_title" : "Traducere automată", "managed-account-field-hint" : "Acesta este gestionat de organizația ta.", @@ -694,15 +700,6 @@ "no_screenshots_yet" : "Nu au fost adăugate încă capturi de ecran.", "operation_not_permitted" : "Permisiunile tale nu sunt suficiente pentru această operațiune.", "operation_not_permitted_error" : "Permisiunile tale nu sunt suficiente pentru această operațiune.", - "order_translation_dialog_cancel" : "Anulare", - "order_translation_dialog_consent" : "Înțeleg că detaliile mele de contact (adresa de email) vor fi partajate cu un furnizor terț și autorizez furnizorul să mă contacteze.", - "order_translation_dialog_note_label" : "Comentariu", - "order_translation_dialog_note_placeholder" : "Vă rugăm să furnizați mai mult context despre proiectul dvs", - "order_translation_dialog_projects_label" : "Selectează proiect(e)", - "order_translation_dialog_send_invitation" : "Aș dori să invit pe {provider} ca membru la proiectele selectate.", - "order_translation_dialog_submit" : "Trimite", - "order_translation_dialog_subtitle" : "Selectați proiectele pentru care doriți să obțineți o estimare de costuri. Tolgee va partaja numărul de cuvinte și limbile cu furnizorul. Furnizorul vă va trimite o estimare prin e-mail.", - "order_translation_dialog_title" : "Obțineți o estimare de costuri de la {provider}", "organization_already_subscribed" : "Organizația este deja abonată la un plan.", "organization-billing-self-hosted-active-subscriptions" : "Abonamente active", "organization-billing-self-hosted-cancel-subscription-button" : "Anulare", @@ -941,7 +938,6 @@ "project_menu_integrate" : "Integrare", "project_menu_languages" : "Limbi", "project_menu_members" : "Membri", - "project_menu_order_translation" : "Comandă serviciul de traducere", "project_menu_projects" : "Proiecte", "project_menu_project_settings" : "Setări proiect", "project_menu_translations" : "Traduceri", @@ -956,8 +952,6 @@ "project_mt_dialog_service_suggested_hint" : "Serviciile afișate în sugestii în panoul de traducere", "project_mt_dialog_settings_inherited_message" : "Moștenit din setările implicite", "project_mt_dialog_title" : "Setări traducere automată", - "project_order_translation_subtitle" : "Comandați traducerea de la traducători profesioniști", - "project_order_translation_title" : "Comandă serviciul de traducere", "project_permission_information_text_base_permission_after" : "Acest lucru înseamnă că fiecare utilizator are cel puțin această permisiune. Nu o puteți seta mai jos de atât.", "project_permission_information_text_base_permission_before" : "Acest proiect face parte din organizația care are permisiuni de bază setate la:", "project_permissions_revoke_user_access_message" : "Sigur doriți să revocați accesul utilizatorului {userName}?", @@ -1080,11 +1074,12 @@ "translation_failed" : "Traducerea a eșuat", "translation_grid_key_text" : "Cheie", "Translation grid - Successfully deleted!" : "Traducerile au fost șterse!", - "translation_provider_get_quote" : "Obțineți o estimare de costuri", + "translation_memory_empty" : "Gol", "translations_auto_translated_provider" : "Tradus automat cu {provider}", "translations_auto_translated_tm" : "Tradus automat cu memorie de traducere", "translations_cell_cancel" : "Anulare", "translations_cell_change_state" : "Schimbare stare", + "translations_cell_close" : "Închide", "translations_cell_edit" : "Editare", "translations_cell_insert_base" : "Inserați textul de bază", "translations_cell_outdated" : "Depășit (traducerea de bază a fost modificată)", @@ -1180,7 +1175,6 @@ "translations_shortcuts_in_editor_title" : "În editor", "translations_shortcuts_in_list_title" : "În listă", "translations_shortcuts_move" : "Mută", - "translations_shortcuts_title" : "Scurtături de tastatură", "translations_tag_create" : "Adaugă „{tag}”", "translations_tag_label" : "etichetă", "translations_tags_no_results" : "Nimic găsit", diff --git a/webapp/src/i18n/ru.json b/webapp/src/i18n/ru.json index 1c87d04fd2..1c778d14c0 100644 --- a/webapp/src/i18n/ru.json +++ b/webapp/src/i18n/ru.json @@ -70,6 +70,7 @@ "administration-debugging-customer-account-message" : "Вы отлаживаете учетную запись пользователя.", "administration_delete_user_button" : "Удалить", "administration_disable_user_button" : "Отключить", + "administration_ee_plan_delete_message" : "Вы действительно хотите удалить этот тарифный план?", "administration_enable_user_button" : "Включить", "administration_organization_projects" : "Проекты", "administration_organizations" : "Организации", diff --git a/webapp/src/i18n/zh.json b/webapp/src/i18n/zh.json index af33ec0bb6..5f48cf6d8a 100644 --- a/webapp/src/i18n/zh.json +++ b/webapp/src/i18n/zh.json @@ -11,8 +11,8 @@ "account-security-mfa-enable-mfa-button" : "启用 2FA", "account-security-mfa-enable-step-one" : "首先,请在您最喜欢的身份验证器中扫描下面的二维码。", "account-security-mfa-enable-step-two" : "然后,请输入应用程序生成的六位数代码和您的密码,以激活双因素身份验证。", - "account-security-mfa-otp-code" : "2FA 代码", - "account-security-mfa-recovery-codes" : "2FA 恢复码", + "account-security-mfa-otp-code" : "2FA 验证码", + "account-security-mfa-recovery-codes" : "2FA 恢复代码", "account-security-mfa-recovery-codes-description" : "以下是您的双因素身份验证恢复代码,请确保将它们存放在安全的地方。", "account-security-mfa-recovery-info" : "恢复代码允许您在无法访问身份验证器时登录您的帐户。我们强烈建议您安全地保存这些代码,以防止您的帐户被锁定。", "account-security-mfa-recovery-info-invalidate" : "注意:查看您的恢复代码将使先前生成的恢复代码被废弃。您必须生成一组新的代码。", @@ -68,8 +68,8 @@ "activity_entity_project.name" : "名称", "activity_entity_screenshot" : "屏幕截图", "activity_entity_translation" : "翻译了", - "activity_entity_translation_comment" : "发表评论", - "activity_entity_translation.flag_emoji" : "发表评论", + "activity_entity_translation_comment" : "发表了评论", + "activity_entity_translation.flag_emoji" : "旗帜", "activity_entity_translation.name" : "名称", "activity_entity_translation.tag" : "标签", "activity_import" : "{KeyCount, plural, \n one {导入了一个词条}\n other {导入了 # 个词条}\n} ({TranslationCount, plural, \n one {1 个翻译} \n other {# 个翻译}\n})", @@ -91,14 +91,14 @@ "add_screenshots_message" : "通过拖放图片或点击加号来添加一些截图。", "administration-access-message" : "您正在以服务器管理员身份访问此页面", "administration_cloud_plan_create" : "创建计划", - "administration_cloud_plan_created_success" : "计划已创建", - "administration_cloud_plan_deleted_success" : "计划已删除", - "administration_cloud_plan_delete_message" : "您真的要删除此定价计划吗?", - "administration_cloud_plan_edit" : "编辑计划", + "administration_cloud_plan_created_success" : "套餐已创建", + "administration_cloud_plan_deleted_success" : "套餐已删除", + "administration_cloud_plan_delete_message" : "您确定要删除此套餐吗?", + "administration_cloud_plan_edit" : "编辑套餐", "administration_cloud_plan_edit_button" : "编辑", "administration_cloud_plan_field_auto-assign-to-selected" : "自动分配给选定的组织", "administration_cloud_plan_field_free" : "免费", - "administration_cloud_plan_field_included_mt_credits" : "机器翻译积分", + "administration_cloud_plan_field_included_mt_credits" : "包含的机器翻译积分", "administration_cloud_plan_field_included_translations" : "翻译", "administration_cloud_plan_field_included_translation_slots" : "翻译插槽", "administration_cloud_plan_field_name" : "名称", @@ -125,14 +125,15 @@ "administration_ee_plan_create" : "创建计划", "administration_ee_plan_created_success" : "计划已创建", "administration_ee_plan_deleted_success" : "计划已删除", - "administration_ee_plan_edit" : "编辑计划", + "administration_ee_plan_delete_message" : "您确定要删除此定价计划吗?", + "administration_ee_plan_edit" : "编辑套餐", "administration_ee_plan_edit_button" : "编辑", "administration_ee_plan_field_free" : "免费", - "administration_ee_plan_field_included_mt_credits" : "包含机器翻译积分", - "administration_ee_plan_field_included_seats" : "座位", + "administration_ee_plan_field_included_mt_credits" : "包含的机器翻译积分", + "administration_ee_plan_field_included_seats" : "席位数", "administration_ee_plan_field_name" : "名称", "administration_ee_plan_field_price_monthly" : "月费", - "administration_ee_plan_field_price_per_seat" : "每个座位", + "administration_ee_plan_field_price_per_seat" : "每个席位", "administration_ee_plan_field_price_per_thousand_mt_credits" : "每 1000 个机器翻译积分", "administration_ee_plan_field_price_yearly" : "年费", "administration_ee_plan_field_public" : "公开", @@ -147,21 +148,21 @@ "administration-exit-debug-customer-account" : "退出调试模式", "administration_organization_projects" : "项目", "administration_organizations" : "团队", - "administration_organizations_settings" : "团队", + "administration_organizations_settings" : "设置", "administration_role_set_success" : "角色已更改", "administration_title" : "服务器管理", "administration_user_debug" : "调试账户", "administration_user_deleted_message" : "用户已删除", - "administration_user_disabled_message" : "用户已禁用", + "administration_user_disabled_message" : "用户已停用", "administration_user_enabled_message" : "用户已启用", "administration_user_role_admin" : "管理员", "administration_user_role_user" : "用户", "administration_users" : "用户", "ai_customization_description" : "由于 Tolgee 的机器翻译是基于人工智能的,您可以通过提供项目描述或添加特定语言的注释来改善翻译。", - "ai_customization_not_enabled_message" : "升级您的计划以访问 AI 定制功能。", + "ai_customization_not_enabled_message" : "升级您的套餐以访问 AI 定制功能。", "ai_customization_title" : "AI 定制", "ai_tips_label" : "提示", - "announcement_feature_ai_customization" : "发布了 Tolgee AI 翻译器的定制功能", + "announcement_feature_ai_customization" : "Tolgee AI 翻译器定制功能已发布", "announcement_feature_batch_operations" : "我们已发布批量操作", "announcement_feature_content_delivery_and_webhooks" : "使用最新发布的内容交付Webhooks加速本地化!", "announcement_feature_mt_formality" : "机器翻译现在支持正式语气", @@ -229,8 +230,8 @@ "batch_operation_type_machine_translation" : "机器翻译", "batch_operation_type_set_keys_namespace" : "更改命名空间", "batch_operation_type_set_translations_state" : "设置翻译状态", - "batch_operation_type_tag_keys" : "标记词条", - "batch_operation_type_untag_keys" : "取消标记词条", + "batch_operation_type_tag_keys" : "添加词条标签", + "batch_operation_type_untag_keys" : "移除词条标签", "batch_select_no_operation" : "无操作", "batch_select_placeholder" : "选择操作...", "billinb_self_hosted_plan_included_mtCredits" : "{mtCredits} 机器翻译积分", @@ -273,7 +274,7 @@ "billing_plan_credits_included" : "每月的机器翻译积分", "billing-plan-monthly-price" : "{pricePerSeat, number, ::precision-integer currency/EUR}/月/人", "billing-plan-price-per-seat-extra" : "+ {price, number, ::precision-integer currency/EUR}/月/人(每增加一个座位)", - "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.00 currency/EUR}每增加1000个MT积分", + "billing-plan-price-per-thousand-mt-credits-extra" : "+ {price, number, ::.000 currency/EUR}每增加1000个MT积分", "billing-plan-price-per-thousand-strings-extra" : "+ {price, number, ::precision-integer currency/EUR", "billing_plan_resubscribe" : "恢复订阅", "billing_plan_strings_included" : "包含的字符串", @@ -300,7 +301,7 @@ "billing_subscriptions_granular_permissions_feature" : "精细权限", "billing_subscriptions_multiple_content_delivery_configs" : "多个内容交付配置", "billing_subscriptions_pay_for_what_you_use" : "只需按实际用量付费。", - "billing_subscriptions_plan_includes_title" : "本套餐包括:", + "billing_subscriptions_plan_includes_title" : "此套餐包含以下内容:", "billing_subscriptions_premium_support_feature" : "高级技术支持", "billing_subscriptions_prioritized_feature_requests" : "功能请求优先考虑", "billing_subscriptions_project_level_content_storages" : "自定义内容存储", @@ -397,10 +398,10 @@ "edit_organization_title" : "团队资料", "edit_pat_title" : "编辑个人访问令牌", "ee_licence_key_apply" : "应用许可证密钥", - "ee_licence_key_hint" : "要创建或管理您的许可证密钥,请转到Tolgee云。", + "ee_licence_key_hint" : "要创建或管理您的许可证密钥,请转到 Tolgee 云。", "ee_licence_key_input_label" : "您的许可证密钥", "ee-license-key-confirmation-message" : "应用此许可证密钥后,当您超出订阅计划中的 {includedSeats, number} 个包含座位时,您将每个座位每月收取 {price, number, ::precision-integer currency/EUR} 的费用。", - "ee-license-key-confirmation-message-estimate" : "根据您当前的使用情况,到本计费周期结束时,您将额外支付{additional, number, ::precision-integer currency/EUR}。", + "ee-license-key-confirmation-message-estimate" : "根据您当前的使用情况,到本计费周期结束时,您将需要额外支付 {additional, number, ::precision-integer currency/EUR} 的费用。", "ee-license-refresh-success-message" : "已成功刷新", "ee-license-refresh-tooltip" : "刷新许可证信息", "ee-license-release-key-button" : "释放许可证密钥", @@ -694,15 +695,6 @@ "no_screenshots_yet" : "暂无屏幕截图。", "operation_not_permitted" : "您的权限不足以执行此操作。", "operation_not_permitted_error" : "您的权限不足以执行此操作。", - "order_translation_dialog_cancel" : "取消", - "order_translation_dialog_consent" : "我理解我的联系方式(电子邮件)将与第三方供应商共享,并授权供应商与我联系。", - "order_translation_dialog_note_label" : "评论", - "order_translation_dialog_note_placeholder" : "请提供有关您的项目的其他上下文信息", - "order_translation_dialog_projects_label" : "选择项目", - "order_translation_dialog_send_invitation" : "我想邀请 {provider} 作为所选项目的成员。", - "order_translation_dialog_submit" : "提交", - "order_translation_dialog_subtitle" : "选择您想获取报价的项目。 Tolgee将与供应商分享字数和语言。 供应商将通过电子邮件向您提供估价。", - "order_translation_dialog_title" : "从 {provider} 获取报价", "organization_already_subscribed" : "该组织已经订阅了某个计划。", "organization-billing-self-hosted-active-subscriptions" : "已激活的订阅", "organization-billing-self-hosted-cancel-subscription-button" : "取消", @@ -941,7 +933,6 @@ "project_menu_integrate" : "集成", "project_menu_languages" : "语言", "project_menu_members" : "成员", - "project_menu_order_translation" : "订购翻译服务", "project_menu_projects" : "项目", "project_menu_project_settings" : "项目设置", "project_menu_translations" : "翻译", @@ -956,8 +947,6 @@ "project_mt_dialog_service_suggested_hint" : "翻译面板中建议显示的服务", "project_mt_dialog_settings_inherited_message" : "从默认设置继承", "project_mt_dialog_title" : "机器翻译设置", - "project_order_translation_subtitle" : "从专业翻译人员订购翻译服务", - "project_order_translation_title" : "订购翻译服务", "project_permission_information_text_base_permission_after" : "这意味着每个用户至少拥有此权限。您不能将其设置得比这更低。", "project_permission_information_text_base_permission_before" : "此项目是组织的一部分,其基本权限设置为:", "project_permissions_revoke_user_access_message" : "您确定要撤销用户 {userName} 的访问权限吗?", @@ -1080,7 +1069,6 @@ "translation_failed" : "翻译失败", "translation_grid_key_text" : "词条", "Translation grid - Successfully deleted!" : "译文已删除!", - "translation_provider_get_quote" : "获取费用估算", "translations_auto_translated_provider" : "使用 {provider} 自动翻译", "translations_auto_translated_tm" : "使用翻译记忆自动翻译", "translations_cell_cancel" : "取消", @@ -1180,7 +1168,6 @@ "translations_shortcuts_in_editor_title" : "编辑器中", "translations_shortcuts_in_list_title" : "列表中", "translations_shortcuts_move" : "移动", - "translations_shortcuts_title" : "键盘快捷键", "translations_tag_create" : "添加 “{tag}”", "translations_tag_label" : "标签", "translations_tags_no_results" : "什么都没有找到", diff --git a/webapp/src/service/TranslationHooks.ts b/webapp/src/service/TranslationHooks.ts index 9a3df826d7..0f2ed5b461 100644 --- a/webapp/src/service/TranslationHooks.ts +++ b/webapp/src/service/TranslationHooks.ts @@ -28,6 +28,7 @@ export const usePutTranslationState = () => useApiMutation({ url: '/v2/projects/{projectId}/translations/{translationId}/set-state/{state}', method: 'put', + invalidatePrefix: '/v2/projects/{projectId}/translations/{translationId}', }); export const usePutTag = () => diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 9228451d0b..cb6601de53 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -83,6 +83,7 @@ export interface paths { put: operations["complexEdit"]; }; "/v2/projects/{projectId}/keys/{id}": { + get: operations["get_5"]; put: operations["edit"]; }; "/v2/projects/{projectId}/invite": { @@ -90,7 +91,7 @@ export interface paths { }; "/v2/projects/{projectId}/content-storages/{contentStorageId}": { /** Get Content Storage */ - get: operations["get_5"]; + get: operations["get_7"]; /** Updates Content Storage */ put: operations["update_3"]; /** Delete Content Storage */ @@ -98,7 +99,7 @@ export interface paths { }; "/v2/projects/{projectId}/content-delivery-configs/{id}": { /** Get Content Delivery Config */ - get: operations["get_6"]; + get: operations["get_8"]; /** Updates Content Delivery Config */ put: operations["update_4"]; /** Publish to Content Delivery */ @@ -149,6 +150,12 @@ export interface paths { /** Imports the data prepared in previous step */ put: operations["applyImport"]; }; + "/v2/projects/{projectId}/import-settings": { + /** Returns import settings for the authenticated user and the project. */ + get: operations["get_9"]; + /** Stores import settings for the authenticated user and the project. */ + put: operations["store"]; + }; "/v2/projects/{projectId}/batch-jobs/{id}/cancel": { put: operations["cancel"]; }; @@ -159,7 +166,7 @@ export interface paths { put: operations["setState"]; }; "/v2/projects/{projectId}/translations/{translationId}/comments/{commentId}": { - get: operations["get_9"]; + get: operations["get_13"]; put: operations["update_5"]; delete: operations["delete_8"]; }; @@ -181,7 +188,7 @@ export interface paths { put: operations["leaveProject"]; }; "/v2/projects/{projectId}/languages/{languageId}": { - get: operations["get_11"]; + get: operations["get_15"]; put: operations["editLanguage"]; delete: operations["deleteLanguage_2"]; }; @@ -206,7 +213,7 @@ export interface paths { put: operations["setPromptProjectCustomization"]; }; "/v2/pats/{id}": { - get: operations["get_13"]; + get: operations["get_17"]; put: operations["update_7"]; delete: operations["delete_10"]; }; @@ -223,7 +230,7 @@ export interface paths { put: operations["setBasePermissions_1"]; }; "/v2/organizations/{id}": { - get: operations["get_15"]; + get: operations["get_19"]; put: operations["update_8"]; delete: operations["delete_11"]; }; @@ -394,7 +401,7 @@ export interface paths { post: operations["exportPost"]; }; "/v2/projects/{projectId}/big-meta": { - post: operations["store"]; + post: operations["store_2"]; }; "/v2/projects/{projectId}/translations/{translationId}/comments": { get: operations["getAll_5"]; @@ -546,7 +553,7 @@ export interface paths { get: operations["currentJobs"]; }; "/v2/projects/{projectId}/batch-jobs/{id}": { - get: operations["get_7"]; + get: operations["get_11"]; }; "/v2/projects/{projectId}/batch-jobs": { get: operations["list_3"]; @@ -588,7 +595,7 @@ export interface paths { get: operations["getCurrent"]; }; "/v2/organizations/{slug}": { - get: operations["get_14"]; + get: operations["get_18"]; }; "/v2/organizations/{slug}/projects": { get: operations["getAllProjects"]; @@ -622,7 +629,7 @@ export interface paths { get: operations["getInfo_3"]; }; "/v2/api-keys/{keyId}": { - get: operations["get_16"]; + get: operations["get_20"]; }; "/v2/api-keys/current": { get: operations["getCurrent_1"]; @@ -729,12 +736,14 @@ export interface components { completedSteps: string[]; open: boolean; }; - EditProjectDTO: { + EditProjectRequest: { name: string; slug?: string; /** Format: int64 */ baseLanguageId?: number; description?: string; + /** @description Whether to use ICU placeholder visualization in the editor and it's support. */ + icuPlaceholders: boolean; }; ComputedPermissionModel: { permissionModel?: components["schemas"]["PermissionModel"]; @@ -746,24 +755,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 @@ -801,6 +792,24 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -903,6 +912,8 @@ export interface components { organizationRole?: "MEMBER" | "OWNER"; directPermission?: components["schemas"]["PermissionModel"]; computedPermission: components["schemas"]["ComputedPermissionModel"]; + /** @description Whether to disable ICU placeholder visualization in the editor and it's support. */ + icuPlaceholders: boolean; }; SimpleOrganizationModel: { /** Format: int64 */ @@ -1089,6 +1100,12 @@ export interface components { relatedKeysInOrder?: components["schemas"]["RelatedKeyDto"][]; /** @description Description of the key. It's also used as a context for Tolgee AI translator */ description?: string; + /** @description If key is pluralized. If it will be reflected in the editor. If null, value won't be modified. */ + isPlural?: boolean; + /** @description The argument name for the plural. If null, value won't be modified. If isPlural is false, this value will be ignored. */ + pluralArgName?: string; + /** @description Custom values of the key. If not provided, custom values won't be modified */ + custom?: { [key: string]: { [key: string]: unknown } }; }; KeyInScreenshotPositionDto: { /** Format: int32 */ @@ -1164,6 +1181,12 @@ export interface components { tags: components["schemas"]["TagModel"][]; /** @description Screenshots of the key */ screenshots: components["schemas"]["ScreenshotModel"][]; + /** @description If key is pluralized. If it will be reflected in the editor */ + isPlural: boolean; + /** @description The argument name for the plural */ + pluralArgName?: string; + /** @description Custom values of the key */ + custom: { [key: string]: { [key: string]: unknown } }; }; /** @description Screenshots of the key */ ScreenshotModel: { @@ -1249,6 +1272,8 @@ export interface components { * @example This key is used on homepage. It's a label of sign up button. */ description?: string; + /** @description Custom values of the key */ + custom?: { [key: string]: { [key: string]: unknown } }; }; ProjectInviteUserDto: { type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; @@ -1306,8 +1331,8 @@ export interface components { }; S3ContentStorageConfigDto: { bucketName: string; - accessKey: string; - secretKey: string; + accessKey?: string; + secretKey?: string; endpoint: string; signingRegion: string; contentStorageType?: "S3" | "AZURE"; @@ -1346,7 +1371,15 @@ export interface components { */ languages?: string[]; /** @description Format to export to */ - format: "JSON" | "XLIFF"; + format: + | "JSON" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES"; /** * @description Delimiter to structure file content. * @@ -1355,6 +1388,12 @@ export interface components { * When null, resulting file won't be structured. */ structureDelimiter?: string; + /** + * @description + * If true, for structured formats (like JSON) arrays are supported. + * e.g. Key hello[0] will be exported as {"hello": ["..."]} + */ + supportArrays: boolean; /** @description Filter key IDs to be contained in export */ filterKeyId?: number[]; /** @description Filter key IDs not to be contained in export */ @@ -1367,6 +1406,12 @@ export interface components { filterState?: ("UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED")[]; /** @description Select one ore multiple namespaces to export */ filterNamespace?: string[]; + /** + * @description Message format to be used for export. (applicable for .po) + * + * e.g. PHP_PO: Hello %s, PYTHON_PO: Hello %(name)s + */ + messageFormat?: "C_SPRINTF" | "PHP_SPRINTF" | "PYTHON_SPRINTF"; }; ContentDeliveryConfigModel: { /** Format: int64 */ @@ -1386,7 +1431,15 @@ export interface components { */ languages?: string[]; /** @description Format to export to */ - format: "JSON" | "XLIFF"; + format: + | "JSON" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES"; /** * @description Delimiter to structure file content. * @@ -1407,6 +1460,18 @@ export interface components { filterState?: ("UNTRANSLATED" | "TRANSLATED" | "REVIEWED" | "DISABLED")[]; /** @description Select one ore multiple namespaces to export */ filterNamespace?: string[]; + /** + * @description Message format to be used for export. (applicable for .po) + * + * e.g. PHP_PO: Hello %s, PYTHON_PO: Hello %(name)s + */ + messageFormat?: "C_SPRINTF" | "PHP_SPRINTF" | "PYTHON_SPRINTF"; + /** + * @description + * If true, for structured formats (like JSON) arrays are supported. + * e.g. Key hello[0] will be exported as {"hello": ["..."]} + */ + supportArrays: boolean; }; TagKeyDto: { name: string; @@ -1415,6 +1480,25 @@ export interface components { namespace?: string; }; StreamingResponseBody: { [key: string]: unknown }; + ImportSettingsRequest: { + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; + }; + IImportSettings: { + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; + }; + ImportSettingsModel: { + settings?: components["schemas"]["IImportSettings"]; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; + }; /** @description User who created the comment */ SimpleUserAccountModel: { /** Format: int64 */ @@ -1488,6 +1572,7 @@ export interface components { * @example homepage */ keyNamespace?: string; + keyIsPlural: boolean; /** * @description Translations object containing values updated in this request * @example [object Object] @@ -1580,15 +1665,15 @@ export interface components { token: string; /** Format: int64 */ id: number; - description: string; - /** Format: int64 */ - lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -1727,15 +1812,15 @@ export interface components { id: number; projectName: string; userFullName?: string; - username?: string; - description: string; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - projectId: number; + lastUsedAt?: number; + username?: string; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -1751,6 +1836,7 @@ export interface components { source: string; target: string; key: string; + keyNamespace?: string; }; Metadata: { examples: components["schemas"]["ExampleItem"][]; @@ -1767,6 +1853,8 @@ export interface components { metadata?: components["schemas"]["Metadata"]; formality?: "FORMAL" | "INFORMAL" | "DEFAULT"; isBatch: boolean; + pluralForms?: { [key: string]: string }; + expectedPluralForms?: string[]; }; MtResult: { translated?: string; @@ -1832,6 +1920,7 @@ export interface components { prices: components["schemas"]["PlanPricesModel"]; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; hasYearlyPrice: boolean; + free: boolean; }; SelfHostedEeSubscriptionModel: { /** Format: int64 */ @@ -1917,7 +2006,7 @@ export interface components { IdentifyRequest: { anonymousUserId: string; }; - CreateProjectDTO: { + CreateProjectRequest: { name: string; languages: components["schemas"]["LanguageRequest"][]; /** @description Slug of your project used in url e.g. "/v2/projects/what-a-project". If not provided, it will be generated */ @@ -1929,6 +2018,8 @@ export interface components { organizationId: number; /** @description Tag of one of created languages, to select it as base language. If not provided, first language will be selected as base. */ baseLanguageTag?: string; + /** @description Whether to use ICU placeholder visualization in the editor and it's support. */ + icuPlaceholders: boolean; }; WebhookTestResponse: { success: boolean; @@ -2033,6 +2124,10 @@ export interface components { * @example This key is used on homepage. It's a label of sign up button. */ description?: string; + /** @description If key is pluralized. If it will be reflected in the editor */ + isPlural: boolean; + /** @description The argument name for the plural. If null, value will be guessed from the values provided in translations. */ + pluralArgName?: string; }; StorageTestResult: { success: boolean; @@ -2124,7 +2219,6 @@ export interface components { | "CANNOT_FIND_BASE_LANGUAGE" | "BASE_LANGUAGE_NOT_FOUND" | "NO_EXPORTED_RESULT" - | "MULTIPLE_FILES_MUST_BE_ZIPPED" | "CANNOT_SET_YOUR_OWN_ROLE" | "ONLY_TRANSLATE_REVIEW_OR_VIEW_PERMISSION_ACCEPTS_VIEW_LANGUAGES" | "OAUTH2_TOKEN_URL_NOT_SET" @@ -2223,7 +2317,17 @@ export interface components { | "USER_IS_SUBSCRIBED_TO_PAID_PLAN" | "CANNOT_CREATE_FREE_PLAN_WITHOUT_FIXED_TYPE" | "CANNOT_MODIFY_PLAN_FREE_STATUS" - | "KEY_ID_NOT_PROVIDED"; + | "KEY_ID_NOT_PROVIDED" + | "FREE_SELF_HOSTED_SEAT_LIMIT_EXCEEDED" + | "ADVANCED_PARAMS_NOT_SUPPORTED" + | "PLURAL_FORMS_NOT_FOUND_FOR_LANGUAGE" + | "NESTED_PLURALS_NOT_SUPPORTED" + | "MESSAGE_IS_NOT_PLURAL" + | "CONTENT_OUTSIDE_PLURAL_FORMS" + | "INVALID_PLURAL_FORM" + | "MULTIPLE_PLURALS_NOT_SUPPORTED" + | "CUSTOM_VALUES_JSON_TOO_LONG" + | "UNSUPPORTED_PO_MESSAGE_FORMAT"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -2375,7 +2479,15 @@ export interface components { */ languages?: string[]; /** @description Format to export to */ - format: "JSON" | "XLIFF"; + format: + | "JSON" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES"; /** * @description Delimiter to structure file content. * @@ -2397,6 +2509,18 @@ export interface components { /** @description Select one ore multiple namespaces to export */ filterNamespace?: string[]; zip: boolean; + /** + * @description Message format to be used for export. (applicable for .po) + * + * e.g. PHP_PO: Hello %s, PYTHON_PO: Hello %(name)s + */ + messageFormat?: "C_SPRINTF" | "PHP_SPRINTF" | "PYTHON_SPRINTF"; + /** + * @description + * If true, for structured formats (like JSON) arrays are supported. + * e.g. Key hello[0] will be exported as {"hello": ["..."]} + */ + supportArrays: boolean; }; BigMetaDto: { /** @description Keys in the document used as a context for machine translation. Keys in the same order as they appear in the document. The order is important! We are using it for graph distance calculation. */ @@ -2424,6 +2548,8 @@ export interface components { targetLanguageId: number; /** @description Text value of base translation. Useful, when base translation is not stored yet. */ baseText?: string; + /** @description Whether base text is plural. This value is ignored if baseText is null. */ + isPlural?: boolean; /** @description List of services to use. If null, then all enabled services are used. */ services?: ("GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE")[]; }; @@ -2578,7 +2704,8 @@ export interface components { | "FEATURE_MT_FORMALITY" | "FEATURE_CONTENT_DELIVERY_AND_WEBHOOKS" | "NEW_PRICING" - | "FEATURE_AI_CUSTOMIZATION"; + | "FEATURE_AI_CUSTOMIZATION" + | "FEATURE_VISUAL_EDITOR"; }; AuthMethodsDTO: { github: components["schemas"]["OAuthPublicConfigDTO"]; @@ -2641,18 +2768,18 @@ export interface components { name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; + basePermissions: components["schemas"]["PermissionModel"]; avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -2762,9 +2889,9 @@ export interface components { /** Format: int64 */ id: number; baseTranslation?: string; + translation?: string; namespace?: string; description?: string; - translation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; @@ -2772,9 +2899,9 @@ export interface components { /** Format: int64 */ id: number; baseTranslation?: string; + translation?: string; namespace?: string; description?: string; - translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -2952,11 +3079,14 @@ export interface components { keyName: string; /** Format: int64 */ keyId: number; + keyDescription?: string; /** Format: int64 */ conflictId?: number; conflictText?: string; override: boolean; resolved: boolean; + isPlural: boolean; + existingKeyIsPlural: boolean; }; PagedModelImportTranslationModel: { _embedded?: { @@ -2977,7 +3107,9 @@ export interface components { | "ID_ATTRIBUTE_NOT_PROVIDED" | "TARGET_NOT_PROVIDED" | "TRANSLATION_TOO_LONG" - | "KEY_IS_BLANK"; + | "KEY_IS_BLANK" + | "TRANSLATION_DEFINED_IN_ANOTHER_FILE" + | "INVALID_CUSTOM_VALUES"; params: components["schemas"]["ImportFileIssueParamModel"][]; }; ImportFileIssueParamModel: { @@ -2988,7 +3120,8 @@ export interface components { | "KEY_INDEX" | "VALUE" | "LINE" - | "FILE_NODE_ORIGINAL"; + | "FILE_NODE_ORIGINAL" + | "LANGUAGE_NAME"; value?: string; }; PagedModelImportFileIssueModel: { @@ -3056,6 +3189,16 @@ export interface components { * @example this_is_super_key */ keyName: string; + /** + * @description Is this key a plural? + * @example true + */ + keyIsPlural: boolean; + /** + * @description The placeholder name for plural parameter + * @example value + */ + keyPluralArgName?: string; /** * Format: int64 * @description The namespace id of the key @@ -3268,6 +3411,8 @@ export interface components { computedPermission: components["schemas"]["ComputedPermissionModel"]; stats: components["schemas"]["ProjectStatistics"]; languages: components["schemas"]["LanguageModel"][]; + /** @description Whether to disable ICU placeholder visualization in the editor and it's support. */ + icuPlaceholders: boolean; }; CollectionModelScreenshotModel: { _embedded?: { @@ -3284,15 +3429,15 @@ export interface components { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ id: number; - description: string; - /** Format: int64 */ - lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + description: string; }; OrganizationRequestParamsDto: { filterCurrentUserOwner: boolean; @@ -3392,6 +3537,7 @@ export interface components { slug?: string; avatar?: components["schemas"]["Avatar"]; baseLanguage?: components["schemas"]["LanguageModel"]; + icuPlaceholders: boolean; }; UserAccountWithOrganizationRoleModel: { /** Format: int64 */ @@ -3412,15 +3558,15 @@ export interface components { id: number; projectName: string; userFullName?: string; - username?: string; - description: string; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - projectId: number; + lastUsedAt?: number; + username?: string; + description: string; }; ApiKeyPermissionsModel: { /** @@ -3477,6 +3623,7 @@ export interface components { )[]; /** @description The user's permission type. This field is null if user has assigned granular permissions or if returning API key's permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + project: components["schemas"]["SimpleProjectModel"]; }; PagedModelUserAccountModel: { _embedded?: { @@ -3953,7 +4100,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["EditProjectDTO"]; + "application/json": components["schemas"]["EditProjectRequest"]; }; }; }; @@ -4430,6 +4577,34 @@ export interface operations { }; }; }; + get_5: { + parameters: { + path: { + id: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["KeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; edit: { parameters: { path: { @@ -4496,7 +4671,7 @@ export interface operations { }; }; /** Get Content Storage */ - get_5: { + get_7: { parameters: { path: { contentStorageId: number; @@ -4584,7 +4759,7 @@ export interface operations { }; }; /** Get Content Delivery Config */ - get_6: { + get_8: { parameters: { path: { id: number; @@ -5031,6 +5206,67 @@ export interface operations { }; }; }; + /** Returns import settings for the authenticated user and the project. */ + get_9: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["ImportSettingsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + }; + /** Stores import settings for the authenticated user and the project. */ + store: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "*/*": components["schemas"]["ImportSettingsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImportSettingsRequest"]; + }; + }; + }; cancel: { parameters: { path: { @@ -5113,7 +5349,7 @@ export interface operations { }; }; }; - get_9: { + get_13: { parameters: { path: { translationId: number; @@ -5448,7 +5684,7 @@ export interface operations { }; }; }; - get_11: { + get_15: { parameters: { path: { languageId: number; @@ -5726,7 +5962,7 @@ export interface operations { }; }; }; - get_13: { + get_17: { parameters: { path: { id: number; @@ -5926,7 +6162,7 @@ export interface operations { }; }; }; - get_15: { + get_19: { parameters: { path: { id: number; @@ -6733,7 +6969,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateProjectDTO"]; + "application/json": components["schemas"]["CreateProjectRequest"]; }; }; }; @@ -7558,6 +7794,8 @@ export interface operations { /** When importing structured JSONs, you can set the delimiter which will be used in names of improted keys. */ structureDelimiter?: string; storeFilesToFileStorage?: boolean; + /** If true, for structured formats (like JSON) arrays are supported. e.g. Array object like {"hello": ["item1", "item2"]} will be imported as keys hello[0] = "item1" and hello[1] = "item2". */ + supportArrays?: boolean; }; path: { projectId: number; @@ -7625,7 +7863,15 @@ export interface operations { */ languages?: string[]; /** Format to export to */ - format?: "JSON" | "XLIFF"; + format?: + | "JSON" + | "XLIFF" + | "PO" + | "APPLE_STRINGS_STRINGSDICT" + | "APPLE_XLIFF" + | "ANDROID_XML" + | "FLUTTER_ARB" + | "PROPERTIES"; /** * Delimiter to structure file content. * @@ -7657,6 +7903,13 @@ export interface operations { * This is possible only when single language is exported. Otherwise it returns "400 - Bad Request" response. */ zip?: boolean; + /** + * Message format to be used for export. (applicable for .po) + * + * e.g. PHP_PO: Hello %s, PYTHON_PO: Hello %(name)s + */ + messageFormat?: "C_SPRINTF" | "PHP_SPRINTF" | "PYTHON_SPRINTF"; + supportArrays?: boolean; }; path: { projectId: number; @@ -7715,7 +7968,7 @@ export interface operations { }; }; }; - store: { + store_2: { parameters: { path: { projectId: number; @@ -9288,7 +9541,7 @@ export interface operations { }; }; }; - get_7: { + get_11: { parameters: { path: { id: number; @@ -9747,7 +10000,7 @@ export interface operations { }; }; }; - get_14: { + get_18: { parameters: { path: { slug: string; @@ -10081,7 +10334,7 @@ export interface operations { }; }; }; - get_16: { + get_20: { parameters: { path: { keyId: number; diff --git a/webapp/src/svgs/logos/android.svg b/webapp/src/svgs/logos/android.svg new file mode 100644 index 0000000000..b334f5b0fe --- /dev/null +++ b/webapp/src/svgs/logos/android.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/webapp/src/svgs/logos/apple.svg b/webapp/src/svgs/logos/apple.svg new file mode 100644 index 0000000000..4fd2f85823 --- /dev/null +++ b/webapp/src/svgs/logos/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/svgs/logos/c.svg b/webapp/src/svgs/logos/c.svg new file mode 100644 index 0000000000..06decd1d2b --- /dev/null +++ b/webapp/src/svgs/logos/c.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/svgs/logos/flutter.svg b/webapp/src/svgs/logos/flutter.svg new file mode 100644 index 0000000000..783f6922fd --- /dev/null +++ b/webapp/src/svgs/logos/flutter.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/svgs/logos/icu.svg b/webapp/src/svgs/logos/icu.svg new file mode 100644 index 0000000000..3cbe5a7f44 --- /dev/null +++ b/webapp/src/svgs/logos/icu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/webapp/src/svgs/logos/php.svg b/webapp/src/svgs/logos/php.svg new file mode 100644 index 0000000000..ad1c0ef4a1 --- /dev/null +++ b/webapp/src/svgs/logos/php.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/svgs/logos/python.svg b/webapp/src/svgs/logos/python.svg new file mode 100644 index 0000000000..790b4a9848 --- /dev/null +++ b/webapp/src/svgs/logos/python.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/src/svgs/tolgeeLogo.svg b/webapp/src/svgs/tolgeeLogo.svg index a3b73933c7..c5db81ac02 100644 --- a/webapp/src/svgs/tolgeeLogo.svg +++ b/webapp/src/svgs/tolgeeLogo.svg @@ -2,6 +2,7 @@ diff --git a/webapp/src/tokens.ts b/webapp/src/tokens.ts new file mode 100644 index 0000000000..82241a956f --- /dev/null +++ b/webapp/src/tokens.ts @@ -0,0 +1,374 @@ +export const COLOR_PINK_50 = '#fdecf2'; +export const COLOR_PINK_100 = '#f9c4d6'; +export const COLOR_PINK_200 = '#f6a7c2'; +export const COLOR_PINK_300 = '#f27fa6'; +export const COLOR_PINK_400 = '#f06695'; +export const COLOR_PINK_400_ALPHA008 = '#f0669514'; +export const COLOR_PINK_400_ALPHA016 = '#f0669529'; +export const COLOR_PINK_500 = '#ec407a'; +export const COLOR_PINK_500_ALPHA004 = '#ec407aa'; +export const COLOR_PINK_500_ALPHA008 = '#ec407a14'; +export const COLOR_PINK_600 = '#d73a6f'; +export const COLOR_PINK_700 = '#a82d57'; +export const COLOR_PINK_800 = '#822343'; +export const COLOR_PINK_900 = '#631b33'; +export const COLOR_GREEN_50 = '#e6faf0'; +export const COLOR_GREEN_100 = '#b0efd1'; +export const COLOR_GREEN_200 = '#8ae7bb'; +export const COLOR_GREEN_300 = '#54dc9d'; +export const COLOR_GREEN_400 = '#33d589'; +export const COLOR_GREEN_500 = '#00cb6c'; +export const COLOR_GREEN_600 = '#00b962'; +export const COLOR_GREEN_700 = '#00904d'; +export const COLOR_GREEN_800 = '#00703b'; +export const COLOR_GREEN_900 = '#00552d'; +export const COLOR_BLUE_GREEN_50 = '#e8fcf8'; +export const COLOR_BLUE_GREEN_100 = '#bef3e9'; +export const COLOR_BLUE_GREEN_200 = '#99e5d6'; +export const COLOR_BLUE_GREEN_300 = '#7ad3c1'; +export const COLOR_BLUE_GREEN_400 = '#35c4b0'; +export const COLOR_BLUE_GREEN_500 = '#00af9a'; +export const COLOR_BLUE_GREEN_600 = '#009b85'; +export const COLOR_BLUE_GREEN_700 = '#008371'; +export const COLOR_BLUE_GREEN_800 = '#006b5b'; +export const COLOR_BLUE_GREEN_900 = '#004437'; +export const COLOR_LIGHT_GRAY_50 = '#fdfdff'; +export const COLOR_LIGHT_GRAY_100 = '#fbfbfd'; +export const COLOR_LIGHT_GRAY_200 = '#f9f9fb'; +export const COLOR_LIGHT_GRAY_300 = '#f6f6f8'; +export const COLOR_LIGHT_GRAY_400 = '#f4f4f6'; +export const COLOR_LIGHT_GRAY_500 = '#f1f1f4'; +export const COLOR_LIGHT_GRAY_600 = '#dbdbde'; +export const COLOR_LIGHT_GRAY_700 = '#ababae'; +export const COLOR_LIGHT_GRAY_800 = '#858587'; +export const COLOR_LIGHT_GRAY_900 = '#656567'; +export const COLOR_GRAY_50 = '#f0f2f4'; +export const COLOR_GRAY_50_ALPHA008 = '#f0f2f414'; +export const COLOR_GRAY_50_ALPHA016 = '#f0f2f429'; +export const COLOR_GRAY_100 = '#d1d6dc'; +export const COLOR_GRAY_200 = '#bbc2cb'; +export const COLOR_GRAY_300 = '#9da7b4'; +export const COLOR_GRAY_400 = '#8995a5'; +export const COLOR_GRAY_500 = '#6c7b8f'; +export const COLOR_GRAY_600 = '#627082'; +export const COLOR_GRAY_700 = '#4d5b6e'; +export const COLOR_GRAY_800 = '#2c3c52'; +export const COLOR_GRAY_850 = '#243245'; +export const COLOR_GRAY_900 = '#1f2d40'; +export const COLOR_GRAY_900_ALPHA004 = '#1f2d40a'; +export const COLOR_GRAY_900_ALPHA008 = '#1f2d4014'; +export const COLOR_GRAY_950 = '#182230'; +export const COLOR_NEUTRAL_WHITE = '#ffffff'; +export const COLOR_NEUTRAL_BLACK = '#000000'; +export const COLOR_YELLOW_500 = '#ffce00'; +export const COLOR_YELLOW_800 = '#cca500'; +export const COLOR_RED_400 = '#ff002e'; +export const COLOR_RED_500 = '#d32f2f'; +export const COLOR_RED_600 = '#c62828'; +export const ICON_PRIMARY = { + light: COLOR_GRAY_600, + dark: COLOR_GRAY_50, +}; +export const ICON_LOGO_SYMBOL = { + light: COLOR_PINK_500, + dark: COLOR_GRAY_50, +}; +export const ICON_LOGO_TEXT = { + light: COLOR_GRAY_800, + dark: COLOR_GRAY_50, +}; +export const ICON_PRIMARY_HOVER = { + light: COLOR_GRAY_800, + dark: COLOR_NEUTRAL_WHITE, +}; +export const ICON_PRIMARY_ACTIVE = { + light: COLOR_PINK_600, + dark: COLOR_PINK_400, +}; +export const ICON_SECONDARY = { + light: COLOR_GRAY_400, + dark: COLOR_GRAY_200, +}; +export const ICON_SECONDARY_HOVER = { + light: COLOR_GRAY_600, + dark: COLOR_GRAY_50, +}; +export const ICON_BACKGROUND_SECONDARY_HOVER = { + light: COLOR_GRAY_900_ALPHA004, + dark: COLOR_GRAY_50_ALPHA008, +}; +export const ICON_SECONDARY_ACTIVE = { + light: COLOR_PINK_600, + dark: COLOR_PINK_400, +}; +export const ICON_BACKGROUND_SECONDARY_ACTIVE = { + light: COLOR_PINK_500_ALPHA004, + dark: COLOR_PINK_400_ALPHA008, +}; +export const TEXT_PRIMARY = { + light: COLOR_GRAY_800, + dark: COLOR_GRAY_50, +}; +export const TEXT_SECONDARY = { + light: COLOR_GRAY_300, + dark: COLOR_GRAY_200, +}; +export const SURFACE_BACKGROUND_TOP_BAR = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_950, +}; +export const SURFACE_BACKGROUND_PRIMARY = { + light: COLOR_LIGHT_GRAY_50, + dark: COLOR_GRAY_900, +}; +export const SURFACE_BACKGROUND_PRIMARY_HOVER = { + light: COLOR_LIGHT_GRAY_100, + dark: COLOR_GRAY_850, +}; +export const SURFACE_BACKGROUND_PRIMARY_ACTIVE = { + light: COLOR_LIGHT_GRAY_200, + dark: COLOR_GRAY_800, +}; +export const SURFACE_BACKGROUND_DRAG_DROP = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_950, +}; +export const SURFACE_BACKGROUND_SECONDARY = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_950, +}; +export const BORDER_LINE_PRIMARY = { + light: COLOR_GRAY_50, + dark: COLOR_GRAY_700, +}; +export const BORDER_PRIMARY = { + light: COLOR_GRAY_100, + dark: COLOR_GRAY_700, +}; +export const BORDER_PRIMARY_HOVER = { + light: COLOR_GRAY_200, + dark: COLOR_GRAY_600, +}; +export const BORDER_SECONDARY = { + light: COLOR_GRAY_50, + dark: COLOR_GRAY_700, +}; +export const BORDER_SECONDARY_DASHED = { + light: COLOR_GRAY_100, + dark: COLOR_GRAY_700, +}; +export const STATE_UNTRANSLATED = { + light: COLOR_GRAY_200, + dark: COLOR_GRAY_400, +}; +export const STATE_TRANSLATED = { + light: COLOR_YELLOW_500, + dark: COLOR_YELLOW_800, +}; +export const STATE_REVIEWED = { + light: COLOR_GREEN_600, + dark: COLOR_GREEN_700, +}; +export const COMPONENTS_LINK_TEXT_PRIMARY = { + light: COLOR_PINK_500, + dark: COLOR_PINK_400, +}; +export const COMPONENTS_BUTTON_BACKGROUND_PRIMARY = { + light: COLOR_PINK_500, + dark: COLOR_PINK_400, +}; +export const COMPONENTS_BUTTON_BACKGROUND_PRIMARY_HOVER = { + light: COLOR_PINK_600, + dark: COLOR_PINK_500, +}; +export const COMPONENTS_BUTTON_TEXT_PRIMARY = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_950, +}; +export const COMPONENTS_BUTTON_BORDER_PRIMARY_OUTLINE = { + light: COLOR_PINK_500, + dark: COLOR_PINK_400, +}; +export const COMPONENTS_BUTTON_BORDER_PRIMARY_OUTLINE_HOVER = { + light: COLOR_PINK_600, + dark: COLOR_PINK_500, +}; +export const COMPONENTS_BUTTON_TEXT_PRIMARY_OUTLINE = { + light: COLOR_PINK_500, + dark: COLOR_PINK_400, +}; +export const COMPONENTS_BUTTON_TEXT_PRIMARY_OUTLINE_HOVER = { + light: COLOR_PINK_600, + dark: COLOR_PINK_500, +}; +export const COMPONENTS_BUTTON_BACKGROUND_SECONDARY_HOVER = { + light: COLOR_LIGHT_GRAY_200, + dark: COLOR_GRAY_800, +}; +export const COMPONENTS_BUTTON_TEXT_SECONDARY = { + light: COLOR_GRAY_700, + dark: COLOR_GRAY_100, +}; +export const COMPONENTS_BUTTON_TEXT_SECONDARY_HOVER = { + light: COLOR_GRAY_900, + dark: COLOR_GRAY_50, +}; +export const COMPONENTS_BUTTON_TEXT_SECONDARY_OUTLINE = { + light: COLOR_GRAY_700, + dark: COLOR_GRAY_100, +}; +export const COMPONENTS_BUTTON_BORDER_SECONDARY_OUTLINE = { + light: COLOR_GRAY_200, + dark: COLOR_GRAY_600, +}; +export const COMPONENTS_BUTTON_BACKGROUND_TERTIARY = { + light: COLOR_LIGHT_GRAY_300, + dark: COLOR_GRAY_700, +}; +export const COMPONENTS_BUTTON_BACKGROUND_TERTIARY_HOVER = { + light: COLOR_LIGHT_GRAY_400, + dark: COLOR_GRAY_600, +}; +export const COMPONENTS_INPUT_BORDER_PRIMARY = { + light: COLOR_GRAY_200, + dark: COLOR_GRAY_600, +}; +export const COMPONENTS_MENU_BACKGROUND = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_800, +}; +export const COMPONENTS_MENU_ITEM_TEXT = TEXT_PRIMARY; +export const COMPONENTS_MENU_ITEM_TEXT_ACTION = { + light: COLOR_PINK_600, + dark: COLOR_PINK_400, +}; +export const COMPONENTS_MENU_ITEM_BACKGROUND_HOVER = { + light: COLOR_GRAY_900_ALPHA004, + dark: COLOR_GRAY_50_ALPHA008, +}; +export const COMPONENTS_MENU_ITEM_BACKGROUND_ACTIVE = { + light: COLOR_PINK_500_ALPHA008, + dark: COLOR_PINK_400_ALPHA016, +}; +export const COMPONENTS_INPUT_BACKGROUND_PRIMARY = { + light: COLOR_NEUTRAL_WHITE, + dark: COLOR_GRAY_900, +}; + +export const ALL_TOKENS = { + COLOR_PINK_50, + COLOR_PINK_100, + COLOR_PINK_200, + COLOR_PINK_300, + COLOR_PINK_400, + COLOR_PINK_400_ALPHA008, + COLOR_PINK_400_ALPHA016, + COLOR_PINK_500, + COLOR_PINK_500_ALPHA004, + COLOR_PINK_500_ALPHA008, + COLOR_PINK_600, + COLOR_PINK_700, + COLOR_PINK_800, + COLOR_PINK_900, + COLOR_GREEN_50, + COLOR_GREEN_100, + COLOR_GREEN_200, + COLOR_GREEN_300, + COLOR_GREEN_400, + COLOR_GREEN_500, + COLOR_GREEN_600, + COLOR_GREEN_700, + COLOR_GREEN_800, + COLOR_GREEN_900, + COLOR_BLUE_GREEN_50, + COLOR_BLUE_GREEN_100, + COLOR_BLUE_GREEN_200, + COLOR_BLUE_GREEN_300, + COLOR_BLUE_GREEN_400, + COLOR_BLUE_GREEN_500, + COLOR_BLUE_GREEN_600, + COLOR_BLUE_GREEN_700, + COLOR_BLUE_GREEN_800, + COLOR_BLUE_GREEN_900, + COLOR_LIGHT_GRAY_50, + COLOR_LIGHT_GRAY_100, + COLOR_LIGHT_GRAY_200, + COLOR_LIGHT_GRAY_300, + COLOR_LIGHT_GRAY_400, + COLOR_LIGHT_GRAY_500, + COLOR_LIGHT_GRAY_600, + COLOR_LIGHT_GRAY_700, + COLOR_LIGHT_GRAY_800, + COLOR_LIGHT_GRAY_900, + COLOR_GRAY_50, + COLOR_GRAY_50_ALPHA008, + COLOR_GRAY_50_ALPHA016, + COLOR_GRAY_100, + COLOR_GRAY_200, + COLOR_GRAY_300, + COLOR_GRAY_400, + COLOR_GRAY_500, + COLOR_GRAY_600, + COLOR_GRAY_700, + COLOR_GRAY_800, + COLOR_GRAY_850, + COLOR_GRAY_900, + COLOR_GRAY_900_ALPHA004, + COLOR_GRAY_900_ALPHA008, + COLOR_GRAY_950, + COLOR_NEUTRAL_WHITE, + COLOR_NEUTRAL_BLACK, + COLOR_YELLOW_500, + COLOR_YELLOW_800, + COLOR_RED_400, + COLOR_RED_500, + COLOR_RED_600, + ICON_PRIMARY, + ICON_LOGO_SYMBOL, + ICON_LOGO_TEXT, + ICON_PRIMARY_HOVER, + ICON_PRIMARY_ACTIVE, + ICON_SECONDARY, + ICON_SECONDARY_HOVER, + ICON_BACKGROUND_SECONDARY_HOVER, + ICON_SECONDARY_ACTIVE, + ICON_BACKGROUND_SECONDARY_ACTIVE, + TEXT_PRIMARY, + TEXT_SECONDARY, + SURFACE_BACKGROUND_TOP_BAR, + SURFACE_BACKGROUND_PRIMARY, + SURFACE_BACKGROUND_PRIMARY_HOVER, + SURFACE_BACKGROUND_PRIMARY_ACTIVE, + SURFACE_BACKGROUND_DRAG_DROP, + SURFACE_BACKGROUND_SECONDARY, + BORDER_LINE_PRIMARY, + BORDER_PRIMARY, + BORDER_PRIMARY_HOVER, + BORDER_SECONDARY, + BORDER_SECONDARY_DASHED, + STATE_UNTRANSLATED, + STATE_TRANSLATED, + STATE_REVIEWED, + COMPONENTS_LINK_TEXT_PRIMARY, + COMPONENTS_BUTTON_BACKGROUND_PRIMARY, + COMPONENTS_BUTTON_BACKGROUND_PRIMARY_HOVER, + COMPONENTS_BUTTON_TEXT_PRIMARY, + COMPONENTS_BUTTON_BORDER_PRIMARY_OUTLINE, + COMPONENTS_BUTTON_BORDER_PRIMARY_OUTLINE_HOVER, + COMPONENTS_BUTTON_TEXT_PRIMARY_OUTLINE, + COMPONENTS_BUTTON_TEXT_PRIMARY_OUTLINE_HOVER, + COMPONENTS_BUTTON_BACKGROUND_SECONDARY_HOVER, + COMPONENTS_BUTTON_TEXT_SECONDARY, + COMPONENTS_BUTTON_TEXT_SECONDARY_HOVER, + COMPONENTS_BUTTON_TEXT_SECONDARY_OUTLINE, + COMPONENTS_BUTTON_BORDER_SECONDARY_OUTLINE, + COMPONENTS_BUTTON_BACKGROUND_TERTIARY, + COMPONENTS_BUTTON_BACKGROUND_TERTIARY_HOVER, + COMPONENTS_INPUT_BORDER_PRIMARY, + COMPONENTS_MENU_BACKGROUND, + COMPONENTS_MENU_ITEM_TEXT, + COMPONENTS_MENU_ITEM_TEXT_ACTION, + COMPONENTS_MENU_ITEM_BACKGROUND_HOVER, + COMPONENTS_MENU_ITEM_BACKGROUND_ACTIVE, + COMPONENTS_INPUT_BACKGROUND_PRIMARY, +}; diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index d28b74ed51..0d95c2d28a 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -117,6 +117,8 @@ export function useErrorTranslation() { return t('credit_spending_limit_exceeded'); case 'subscription_not_active': return t('subscription_not_active'); + case 'invalid_plural_form': + return t('invalid_plural_form'); default: return code; } diff --git a/webapp/src/translationTools/useFileIssueParamTranslation.ts b/webapp/src/translationTools/useFileIssueParamTranslation.ts index a538c0ec83..895779f593 100644 --- a/webapp/src/translationTools/useFileIssueParamTranslation.ts +++ b/webapp/src/translationTools/useFileIssueParamTranslation.ts @@ -10,6 +10,12 @@ export const useFileIssuePeramTranslation = () => { return t('import_file_issue_param_type_key_index', { value }); case 'key_name': return t('import_file_issue_param_type_key_name', { value }); + case 'key_id': + return t('import_file_issue_param_type_key_id', { value }); + case 'language_id': + return t('import_file_issue_param_type_language_id', { value }); + case 'language_name': + return t('import_file_issue_param_type_language_name', { value }); case 'line': return t('import_file_issue_param_type_line', { value }); case 'value': diff --git a/webapp/src/translationTools/useFileIssueTranslation.ts b/webapp/src/translationTools/useFileIssueTranslation.ts index 0908c6f1be..e1dc42e130 100644 --- a/webapp/src/translationTools/useFileIssueTranslation.ts +++ b/webapp/src/translationTools/useFileIssueTranslation.ts @@ -20,8 +20,12 @@ export const useFileIssueTranslation = () => { return t('file_issue_type_value_is_empty'); case 'value_is_not_string': return t('file_issue_type_value_is_not_string'); + case 'translation_defined_in_another_file': + return t('translation_defined_in_another_file'); case 'key_is_blank': return t('key_is_blank'); + case 'multiple_values_for_key_and_language': + return t('multiple_values_for_key_and_language'); default: return type; } diff --git a/webapp/src/views/administration/eeLicense/EeLicenseHint.tsx b/webapp/src/views/administration/eeLicense/EeLicenseHint.tsx index e95155a200..db1d2d961c 100644 --- a/webapp/src/views/administration/eeLicense/EeLicenseHint.tsx +++ b/webapp/src/views/administration/eeLicense/EeLicenseHint.tsx @@ -6,16 +6,15 @@ import { LINKS } from 'tg.constants/links'; const TOLGEE_APP = 'https://app.tolgee.io'; export function EeLicenseHint() { + const link = ( + + ); return ( - ), + a: link, }} /> diff --git a/webapp/src/views/projects/ProjectRouter.tsx b/webapp/src/views/projects/ProjectRouter.tsx index cd8b578be7..57a8706f1a 100644 --- a/webapp/src/views/projects/ProjectRouter.tsx +++ b/webapp/src/views/projects/ProjectRouter.tsx @@ -48,7 +48,7 @@ export const ProjectRouter = () => { - + diff --git a/webapp/src/views/projects/developer/contentDelivery/CdEditDialog.tsx b/webapp/src/views/projects/developer/contentDelivery/CdEditDialog.tsx index 371dec0ead..57446abc9f 100644 --- a/webapp/src/views/projects/developer/contentDelivery/CdEditDialog.tsx +++ b/webapp/src/views/projects/developer/contentDelivery/CdEditDialog.tsx @@ -17,12 +17,8 @@ import { EXPORTABLE_STATES, StateType } from 'tg.constants/translationStates'; import LoadingButton from 'tg.component/common/form/LoadingButton'; import { StateSelector } from 'tg.views/projects/export/components/StateSelector'; import { LanguageSelector } from 'tg.views/projects/export/components/LanguageSelector'; -import { - FORMATS, - FormatSelector, -} from 'tg.views/projects/export/components/FormatSelector'; +import { FormatSelector } from 'tg.views/projects/export/components/FormatSelector'; import { NsSelector } from 'tg.views/projects/export/components/NsSelector'; -import { NestedSelector } from 'tg.views/projects/export/components/NestedSelector'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { components } from 'tg.service/apiSchema.generated'; @@ -33,6 +29,12 @@ import { CdStorageSelector } from './CdStorageSelector'; import { CdAutoPublish } from './CdAutoPublish'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { + findByExportParams, + formatGroups, + getFormatById, +} from '../../export/components/formatGroups'; +import { SupportArraysSelector } from '../../export/components/SupportArraysSelector'; type ContentDeliveryConfigModel = components['schemas']['ContentDeliveryConfigModel']; @@ -47,8 +49,6 @@ const EXPORT_DEFAULT_STATES: StateType[] = sortStates([ 'REVIEWED', ]); -const EXPORT_DEFAULT_FORMAT: (typeof FORMATS)[number] = 'JSON'; - const StyledDialogContent = styled(DialogContent)` display: grid; gap: ${({ theme }) => theme.spacing(3)}; @@ -156,6 +156,10 @@ export const CdEditDialog = ({ onClose, data }: Props) => { }); } + const initialFormat = data + ? findByExportParams(data) + : formatGroups[0].formats[0]; + return ( {languagesLoadable.isFetching || namespacesLoadable.isFetching ? ( @@ -168,16 +172,23 @@ export const CdEditDialog = ({ onClose, data }: Props) => { name: data?.name ?? '', states: data?.filterState ?? EXPORT_DEFAULT_STATES, languages: data?.languages ?? allowedTags, - format: data?.format ?? EXPORT_DEFAULT_FORMAT, + format: initialFormat.id, namespaces: allNamespaces ?? [], autoPublish: data?.autoPublish ?? true, - nested: data?.structureDelimiter === '.', + nested: initialFormat.canBeStructured + ? data?.structureDelimiter === '.' + : false, contentStorageId: data?.storage?.id, + supportArrays: + data?.supportArrays !== undefined + ? data.supportArrays + : initialFormat.defaultSupportArrays || false, }} validationSchema={Validation.CONTENT_DELIVERY_FORM} validateOnBlur={false} enableReinitialize={false} onSubmit={(values, actions) => { + const format = getFormatById(values.format); if (data) { updateCd.mutate( { @@ -185,16 +196,20 @@ export const CdEditDialog = ({ onClose, data }: Props) => { content: { 'application/json': { name: values.name, - format: values.format, + format: format.format, filterState: values.states, languages: values.languages, - structureDelimiter: values.nested ? '.' : '', + structureDelimiter: format.canBeStructured + ? format.defaultStructureDelimiter + : '', filterNamespace: undefinedIfAllNamespaces( values.namespaces, allNamespaces ), autoPublish: values.autoPublish, contentStorageId: values.contentStorageId, + supportArrays: values.supportArrays || false, + messageFormat: format.messageFormat, }, }, }, @@ -217,16 +232,20 @@ export const CdEditDialog = ({ onClose, data }: Props) => { content: { 'application/json': { name: values.name, - format: values.format, + format: format.format, filterState: values.states, languages: values.languages, - structureDelimiter: values.nested ? '.' : '', + structureDelimiter: format.canBeStructured + ? format.defaultStructureDelimiter + : '', filterNamespace: undefinedIfAllNamespaces( values.namespaces, allNamespaces ), autoPublish: values.autoPublish, contentStorageId: values.contentStorageId, + supportArrays: values.supportArrays || false, + messageFormat: format.messageFormat, }, }, }, @@ -245,81 +264,86 @@ export const CdEditDialog = ({ onClose, data }: Props) => { } }} > - {({ isSubmitting, handleSubmit, isValid, values }) => ( - <> - - {data - ? t('content_delivery_update_title') - : t('content_delivery_create_title')} - - - - - - - - - - - - - - {Boolean( - storagesLoadable.data?._embedded?.contentStorages?.length - ) && ( + {({ isSubmitting, handleSubmit, isValid, values }) => { + return ( + <> + + {data + ? t('content_delivery_update_title') + : t('content_delivery_create_title')} + + - - )} + + + + + + + + + {Boolean( + storagesLoadable.data?._embedded?.contentStorages?.length + ) && ( + + + + )} - - {values.format === 'JSON' && } - - - - -
- {data && ( - + )} +
+ + - )} - - - - handleSubmit()} - > - {t('content_delivery_form_save')} - - -
- - )} + handleSubmit()} + > + {t('content_delivery_form_save')} + +
+ + + ); + }} )} diff --git a/webapp/src/views/projects/export/ExportForm.tsx b/webapp/src/views/projects/export/ExportForm.tsx index 74b43c023f..fe8481f334 100644 --- a/webapp/src/views/projects/export/ExportForm.tsx +++ b/webapp/src/views/projects/export/ExportForm.tsx @@ -9,16 +9,14 @@ import { EXPORTABLE_STATES, StateType } from 'tg.constants/translationStates'; import LoadingButton from 'tg.component/common/form/LoadingButton'; import { StateSelector } from 'tg.views/projects/export/components/StateSelector'; import { LanguageSelector } from 'tg.views/projects/export/components/LanguageSelector'; -import { - FORMATS, - FormatSelector, -} from 'tg.views/projects/export/components/FormatSelector'; +import { FormatSelector } from 'tg.views/projects/export/components/FormatSelector'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { NsSelector } from 'tg.views/projects/export/components/NsSelector'; -import { NestedSelector } from 'tg.views/projects/export/components/NestedSelector'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { BoxLoading } from 'tg.component/common/BoxLoading'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; +import { SupportArraysSelector } from './components/SupportArraysSelector'; +import { formatGroups, getFormatById } from './components/formatGroups'; const sortStates = (arr: StateType[]) => [...arr].sort( @@ -30,8 +28,6 @@ const EXPORT_DEFAULT_STATES: StateType[] = sortStates([ 'REVIEWED', ]); -const EXPORT_DEFAULT_FORMAT: (typeof FORMATS)[number] = 'JSON'; - const StyledForm = styled('form')` display: grid; border: 1px solid ${({ theme }) => theme.palette.divider1}; @@ -44,19 +40,24 @@ const StyledForm = styled('form')` 'langs format' 'ns ns ' 'options submit'; + & .states { grid-area: states; } + & .langs { grid-area: langs; } + & .format { grid-area: format; } + & .submit { grid-area: submit; justify-self: end; } + & .ns { grid-area: ns; } @@ -131,8 +132,10 @@ export const ExportForm = () => { allowedTags.includes(l) ); - const [format, setFormat] = useUrlSearchState('format', { - defaultVal: EXPORT_DEFAULT_FORMAT, + const defaultFormat = formatGroups[0].formats[0]; + + const [urlFormat, setUrlFormat] = useUrlSearchState('format', { + defaultVal: defaultFormat.id, }); const [nested, setNested] = useUrlSearchState('nested', { @@ -154,9 +157,13 @@ export const ExportForm = () => { ? states : EXPORT_DEFAULT_STATES) as StateType[], languages: (languages?.length ? selectedTags : allowedTags) as string[], - format: (format || EXPORT_DEFAULT_FORMAT) as (typeof FORMATS)[number], + format: (urlFormat as string) || defaultFormat.id, namespaces: allNamespaces || [], nested: nested === 'true', + supportArrays: + (urlFormat + ? getFormatById(urlFormat as string).defaultSupportArrays + : defaultFormat.defaultSupportArrays) || false, }} validate={(values) => { const errors: FormikErrors = {}; @@ -171,26 +178,30 @@ export const ExportForm = () => { // store values in url setStates(sortStates(values.states)); setLanguages(sortLanguages(values.languages)); - setFormat(values.format); + setUrlFormat(values.format); setNested(String(values.nested)); - return errors; }} validateOnBlur={false} enableReinitialize={false} onSubmit={(values, actions) => { + const format = getFormatById(values.format); exportLoadable.mutate( { path: { projectId: project.id }, content: { 'application/json': { - format: values.format, + format: format.format, filterState: values.states, languages: values.languages, - structureDelimiter: values.nested ? '.' : '', + structureDelimiter: format.canBeStructured + ? format.defaultStructureDelimiter + : '', filterNamespace: values.namespaces, zip: values.languages.length > 1 || values.namespaces.length > 1, + supportArrays: values.supportArrays || false, + messageFormat: format.messageFormat, }, }, }, @@ -198,13 +209,19 @@ export const ExportForm = () => { onSuccess(data) { const url = URL.createObjectURL(data as any as Blob); const a = document.createElement('a'); + const onlyPossibleLanguageString = + values.languages.length === 1 ? `_${values.languages[0]}` : ''; a.href = url; + const dateStr = '_' + new Date().toISOString().split('T')[0]; if (data.type === 'application/zip') { - a.download = project.name + '.zip'; - } else if (data.type === 'application/json') { - a.download = values.languages[0] + '.json'; - } else if (data.type === 'application/x-xliff+xml') { - a.download = values.languages[0] + '.xliff'; + a.download = project.name + dateStr + '.zip'; + } else { + a.download = + project.name + + onlyPossibleLanguageString + + dateStr + + '.' + + format.extension; } a.click(); }, @@ -226,10 +243,12 @@ export const ExportForm = () => { - {values.format === 'JSON' && ( - - - + {getFormatById(values.format).defaultSupportArrays && ( + <> + + + + )}
diff --git a/webapp/src/views/projects/export/components/FormatSelector.tsx b/webapp/src/views/projects/export/components/FormatSelector.tsx index d2e626ba48..3e3da89d51 100644 --- a/webapp/src/views/projects/export/components/FormatSelector.tsx +++ b/webapp/src/views/projects/export/components/FormatSelector.tsx @@ -1,8 +1,14 @@ -import { Field } from 'formik'; -import { MenuItem, Select, FormControl, InputLabel } from '@mui/material'; +import { useField } from 'formik'; +import { FormControl, InputLabel, Select } from '@mui/material'; import { useTranslate } from '@tolgee/react'; +import { stopAndPrevent } from 'tg.fixtures/eventHandler'; -export const FORMATS = ['JSON', 'XLIFF'] as const; +import React, { ReactNode } from 'react'; +import { formatGroups, getFormatById } from './formatGroups'; +import { + CompactListSubheader, + CompactMenuItem, +} from 'tg.component/ListComponents'; type Props = { className: string; @@ -10,31 +16,47 @@ type Props = { export const FormatSelector: React.FC = ({ className }) => { const { t } = useTranslate(); + const [field, _, fieldHelperProps] = useField('format'); + + const options: ReactNode[] = []; + + formatGroups.map((group) => { + options.push( + + {group.name} + + ); + group.formats.forEach((option) => + options.push( + { + fieldHelperProps.setValue(option.id); + })} + > + {option.name} + + ) + ); + }); return ( - - {({ field }) => { - return ( - - {t('export_translations_format_label')} - - - ); - }} - + + {t('export_translations_format_label')} + + ); }; diff --git a/webapp/src/views/projects/export/components/SupportArraysSelector.tsx b/webapp/src/views/projects/export/components/SupportArraysSelector.tsx new file mode 100644 index 0000000000..301a4cc219 --- /dev/null +++ b/webapp/src/views/projects/export/components/SupportArraysSelector.tsx @@ -0,0 +1,52 @@ +import { Field } from 'formik'; +import { useTranslate } from '@tolgee/react'; +import { Checkbox, FormControlLabel, Tooltip, styled } from '@mui/material'; +import { Help } from '@mui/icons-material'; + +const StyledLabel = styled('div')` + display: flex; + gap: 5px; + align-items: center; +`; + +const StyledHelpIcon = styled(Help)` + font-size: 17px; +`; + +type Props = { + className?: string; +}; + +export const SupportArraysSelector: React.FC = ({ className }) => { + const { t } = useTranslate(); + + return ( + + {({ field }) => { + return ( + +
{t('export_translations_support_arrays_label')}
+ + + + + } + control={ + <> + + + } + /> + ); + }} +
+ ); +}; diff --git a/webapp/src/views/projects/export/components/formatGroups.tsx b/webapp/src/views/projects/export/components/formatGroups.tsx new file mode 100644 index 0000000000..3d0bbe228d --- /dev/null +++ b/webapp/src/views/projects/export/components/formatGroups.tsx @@ -0,0 +1,171 @@ +import { components } from 'tg.service/apiSchema.generated'; + +export interface FormatItem { + id: string; + name: string; + defaultStructureDelimiter?: string; + showSupportArrays?: boolean; + defaultSupportArrays?: boolean; + canBeStructured?: boolean; + format: components['schemas']['ExportParams']['format']; + messageFormat?: components['schemas']['ExportParams']['messageFormat']; + matchByExportParams?: (params: ExportParamsWithoutZip) => boolean; + extension: string; +} + +export interface FormatGroup { + name: string; + formats: FormatItem[]; +} + +export const formatGroups: FormatGroup[] = [ + { + name: 'Tolgee Native', + formats: [ + { + id: 'native_json', + extension: 'json', + name: 'JSON', + defaultStructureDelimiter: '', + canBeStructured: false, + showSupportArrays: false, + defaultSupportArrays: false, + format: 'JSON', + matchByExportParams: (params) => + params.format === 'JSON' && + params.structureDelimiter === '' && + !params.supportArrays, + }, + ], + }, + { + name: 'Generic', + formats: [ + { + id: 'generic_xliff', + extension: 'xliff', + name: 'XLIFF', + format: 'XLIFF', + }, + { + id: 'generic_structured_json', + extension: 'json', + name: 'Structured JSON', + defaultStructureDelimiter: '.', + canBeStructured: true, + showSupportArrays: true, + defaultSupportArrays: true, + format: 'JSON', + matchByExportParams: (params) => + params.format === 'JSON' && params.structureDelimiter === '.', + }, + { + id: 'properties', + extension: 'properties', + name: '.properties', + format: 'PROPERTIES', + }, + ], + }, + { + name: 'Gettext (.po)', + formats: [ + { + id: 'po_php', + extension: 'po', + name: 'PHP .po', + format: 'PO', + messageFormat: 'PHP_SPRINTF', + }, + // { + // id: 'po_python', + // extension: 'po', + // name: 'Python .po', + // format: 'PO', + // messageFormat: 'PYTHON_SPRINTF', + // }, + { + id: 'po_c', + extension: 'po', + name: 'C/C++ .po', + format: 'PO', + messageFormat: 'C_SPRINTF', + }, + ], + }, + { + name: 'Apple', + formats: [ + { + id: 'apple_strings', + extension: 'strings', + name: 'Apple .strings & .stringsdict', + format: 'APPLE_STRINGS_STRINGSDICT', + }, + { + id: 'apple_xliff', + extension: 'xliff', + name: 'Apple .xliff', + format: 'APPLE_XLIFF', + }, + ], + }, + { + name: 'Android', + formats: [ + { + id: 'android_xml', + extension: 'xml', + name: 'Android .xml', + format: 'ANDROID_XML', + }, + ], + }, + { + name: 'Flutter', + formats: [ + { + id: 'flutter_arb', + extension: 'arb', + name: 'Flutter .arb', + format: 'FLUTTER_ARB', + }, + ], + }, +]; + +type ExportParamsWithoutZip = Omit< + components['schemas']['ExportParams'], + 'zip' +>; + +export const findByExportParams = (params: ExportParamsWithoutZip) => { + return ( + formatGroups + .map((g) => + g.formats.find((f) => { + if (f.matchByExportParams === undefined) { + return ( + f.format === params.format && + ((f.messageFormat === undefined && + params.messageFormat === null) || + f.messageFormat === params.messageFormat) + ); + } + return f.matchByExportParams(params); + }) + ) + .find((f) => f) || formatGroups[0].formats[0] + ); +}; + +export const getFormatById = (id: string): FormatItem => { + for (const group of formatGroups) { + for (const format of group.formats) { + if (format.id === id) { + return format; + } + } + } + return formatGroups[0].formats[0]; +}; diff --git a/webapp/src/views/projects/import/ImportView.tsx b/webapp/src/views/projects/import/ImportView.tsx index 238e8a0ddb..867265732b 100644 --- a/webapp/src/views/projects/import/ImportView.tsx +++ b/webapp/src/views/projects/import/ImportView.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useEffect, useState } from 'react'; +import React, { FunctionComponent, useEffect, useState } from 'react'; import { Box, Button } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; @@ -20,6 +20,7 @@ import { useApplyImportHelper } from './hooks/useApplyImportHelper'; import { useImportDataHelper } from './hooks/useImportDataHelper'; import { BaseProjectView } from '../BaseProjectView'; import { ImportResultLoadingOverlay } from './component/ImportResultLoadingOverlay'; +import { ImportSettingsPanel } from './component/ImportSettingsPanel'; export const ImportView: FunctionComponent = () => { const dataHelper = useImportDataHelper(); @@ -124,6 +125,8 @@ export const ImportView: FunctionComponent = () => { ))} + + void; onDetectedExpandability: (expandable: boolean) => void; expandable: boolean; + languageTag: string; + isPlural: boolean; 'data-cy': string; }; @@ -123,7 +126,12 @@ export const ImportConflictTranslation: React.FC = (props) => { textOverflow="ellipsis" ref={textRef} > - {props.text} + {props.expandable && ( = ({ translation, languageId }) => { + row: components['schemas']['ImportLanguageModel']; +}> = ({ translation, row }) => { const project = useProject(); const [expanded, setExpanded] = useState(false); const [leftExpandable, setLeftExpandable] = useState(false); @@ -36,7 +36,7 @@ export const ImportConflictTranslationsPair: FunctionComponent<{ setOverrideMutation.mutate({ path: { projectId: project.id, - languageId: languageId, + languageId: row.id, translationId: translationId, }, }); @@ -46,7 +46,7 @@ export const ImportConflictTranslationsPair: FunctionComponent<{ setKeepMutation.mutate({ path: { projectId: project.id, - languageId: languageId, + languageId: row.id, translationId: translationId, }, }); @@ -80,6 +80,8 @@ export const ImportConflictTranslationsPair: FunctionComponent<{ expanded={expanded} onDetectedExpandability={setLeftExpandable} expandable={leftExpandable || rightExpandable} + languageTag={row.existingLanguageTag || 'en'} + isPlural={translation.existingKeyIsPlural} /> @@ -97,6 +99,8 @@ export const ImportConflictTranslationsPair: FunctionComponent<{ expanded={expanded} onDetectedExpandability={setRightExpandable} expandable={leftExpandable || rightExpandable} + languageTag={row.existingLanguageTag || 'en'} + isPlural={translation.existingKeyIsPlural || translation.isPlural} /> diff --git a/webapp/src/views/projects/import/component/ImportConflictsData.tsx b/webapp/src/views/projects/import/component/ImportConflictsData.tsx index a2a65cc3b3..51f02dfee9 100644 --- a/webapp/src/views/projects/import/component/ImportConflictsData.tsx +++ b/webapp/src/views/projects/import/component/ImportConflictsData.tsx @@ -63,7 +63,7 @@ export const ImportConflictsData: FunctionComponent<{ diff --git a/webapp/src/views/projects/import/component/ImportFileDropzone.tsx b/webapp/src/views/projects/import/component/ImportFileDropzone.tsx index 0523698f18..e0ced7207f 100644 --- a/webapp/src/views/projects/import/component/ImportFileDropzone.tsx +++ b/webapp/src/views/projects/import/component/ImportFileDropzone.tsx @@ -4,13 +4,17 @@ import { green, red } from '@mui/material/colors'; import { HighlightOff } from '@mui/icons-material'; import React, { FunctionComponent, useState } from 'react'; -import { FileUploadFixtures } from 'tg.fixtures/FileUploadFixtures'; +import { + FilesType, + FileUploadFixtures, + getFilesAsync, +} from 'tg.fixtures/FileUploadFixtures'; import { MAX_FILE_COUNT } from './ImportFileInput'; import { DropzoneIcon } from 'tg.component/CustomIcons'; export interface ScreenshotDropzoneProps { - onNewFiles: (files: File[]) => void; + onNewFiles: (files: FilesType) => void; active: boolean; } @@ -18,7 +22,8 @@ const StyledWrapper = styled(Box)` pointer-events: none; opacity: 0; transition: opacity 0.2s; - background-color: ${({ theme }) => theme.palette.background.paper}; + background-color: ${({ theme }) => + theme.palette.tokens.SURFACE_BACKGROUND_DRAG_DROP}; &:before { content: ''; @@ -104,12 +109,9 @@ export const ImportFileDropzone: FunctionComponent = ( } e.stopPropagation(); e.preventDefault(); - if (e.dataTransfer.items) { - const files = FileUploadFixtures.dataTransferItemsToArray( - e.dataTransfer.items - ); - props.onNewFiles(files); - } + + const files = await getFilesAsync(e.dataTransfer); + props.onNewFiles(files); setDragOver(null); }; diff --git a/webapp/src/views/projects/import/component/ImportFileInput.tsx b/webapp/src/views/projects/import/component/ImportFileInput.tsx index 41994cce31..84e0855f43 100644 --- a/webapp/src/views/projects/import/component/ImportFileInput.tsx +++ b/webapp/src/views/projects/import/component/ImportFileInput.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, ReactNode, useState } from 'react'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; -import { Box, Button, styled, Typography } from '@mui/material'; +import { Box, Button, styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { useConfig } from 'tg.globalContext/helpers'; @@ -16,6 +16,8 @@ import { ImportInputAreaLayoutTitle, ImportInputAreaLayoutTop, } from './ImportInputAreaLayout'; +import { FilesType } from 'tg.fixtures/FileUploadFixtures'; +import { ImportSupportedFormats } from './ImportSupportedFormats'; export const MAX_FILE_COUNT = 20; @@ -28,7 +30,7 @@ export type OperationStatusType = | 'FINALIZING'; type ImportFileInputProps = { - onNewFiles: (files: File[]) => void; + onNewFiles: (files: FilesType) => void; loading: boolean; operation?: OperationType; operationStatus?: OperationStatusType; @@ -46,11 +48,11 @@ export type ValidationResult = { const StyledRoot = styled(Box)(({ theme }) => ({ borderRadius: theme.shape.borderRadius, - border: `1px dashed ${theme.palette.emphasis[100]}`, + border: `1px dashed ${theme.palette.tokens.BORDER_SECONDARY_DASHED}`, margin: '0px auto', width: '100%', position: 'relative', - backgroundColor: theme.palette.background.paper, + backgroundColor: theme.palette.tokens.SURFACE_BACKGROUND_DRAG_DROP, marginTop: '16px', })); @@ -58,14 +60,6 @@ const ImportFileInput: FunctionComponent = (props) => { const { t } = useTranslate(); const fileRef = React.createRef(); const config = useConfig(); - const ALLOWED_EXTENSIONS = [ - 'json', - 'zip', - 'po', - 'xliff', - 'xlf', - 'properties', - ]; const [resetKey, setResetKey] = useState(0); function resetInput() { @@ -88,7 +82,7 @@ const ImportFileInput: FunctionComponent = (props) => { files.push(item); } } - props.onNewFiles(files); + props.onNewFiles(files.map((f) => ({ file: f, name: f.name }))); }; window.addEventListener('dragover', listener, false); @@ -114,12 +108,12 @@ const ImportFileInput: FunctionComponent = (props) => { filtered.push(item); } } - onNewFiles(filtered); + onNewFiles(filtered.map((f) => ({ file: f, name: f.name }))); } - const onNewFiles = (files: File[]) => { + const onNewFiles = (files: FilesType) => { resetInput(); - const validation = validate(files); + const validation = validate(files.map((f) => f.file)); if (validation.valid) { props.onNewFiles(files); return; @@ -148,22 +142,14 @@ const ImportFileInput: FunctionComponent = (props) => { /> ); } - const extension = - file.name.indexOf('.') > -1 ? file.name.replace(/.*\.(.+)$/, '$1') : ''; - if (ALLOWED_EXTENSIONS.indexOf(extension) < 0) { - result.errors.push( - - ); - } }); const valid = result.errors.length === 0; return { ...result, valid }; }; + /* + @ts-ignore */ return ( = (props) => { ref={fileRef} onChange={(e) => onFileSelected(e)} multiple - accept={ALLOWED_EXTENSIONS.join(',')} + webkitdirectory /> @@ -214,9 +200,7 @@ const ImportFileInput: FunctionComponent = (props) => { - - - + diff --git a/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx b/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx new file mode 100644 index 0000000000..1d1f61015a --- /dev/null +++ b/webapp/src/views/projects/import/component/ImportSettingsPanel.tsx @@ -0,0 +1,150 @@ +import React, { FC, useState } from 'react'; +import { Box, styled } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { LoadingCheckboxWithSkeleton } from 'tg.component/common/form/LoadingCheckboxWithSkeleton'; +import { HelpOutline } from '@mui/icons-material'; +import { DOC_LINKS } from '../../../../docLinks'; + +type ImportSettingRequest = components['schemas']['ImportSettingsRequest']; +type ImportSettingModel = components['schemas']['ImportSettingsModel']; + +const StyledPanelBox = styled(Box)` + margin-top: 24px; + border: 1px solid ${({ theme }) => theme.palette.tokens.BORDER_SECONDARY}; + display: flex; + width: 1200px; + padding: 6px 16px; + justify-content: center; + align-items: center; + gap: 20px; + border-radius: 4px; + background-color: ${({ theme }) => + theme.palette.tokens.SURFACE_BACKGROUND_SECONDARY}; +`; + +export const ImportSettingsPanel: FC = (props) => { + const project = useProject(); + const { t } = useTranslate(); + + const [state, setState] = useState( + undefined + ); + + const [loadingItems, setLoadingItems] = useState< + Set + >(new Set()); + + useApiQuery({ + url: '/v2/projects/{projectId}/import-settings', + method: 'get', + path: { projectId: project.id }, + options: { + onSuccess: (data) => { + setState(data); + }, + }, + }); + + const updateSettings = useApiMutation({ + url: '/v2/projects/{projectId}/import-settings', + method: 'put', + invalidatePrefix: '/v2/projects/{projectId}/import', + }); + + function onChange( + item: T, + value: ImportSettingRequest[T] + ) { + if (state == undefined) { + return; + } + const onSuccess = (data: ImportSettingModel) => { + setState(data); + }; + + const onSettled = () => { + setLoadingItems((loadingItems) => { + const copy = new Set([...loadingItems]); + copy.delete(item); + return copy; + }); + }; + + const newValue = { ...state, [item]: value }; + setLoadingItems((loadingItems) => { + const copy = new Set([...loadingItems]); + copy.add(item); + return copy; + }); + updateSettings.mutate( + { + path: { projectId: project.id }, + content: { 'application/json': newValue }, + }, + { + onSuccess, + onSettled, + } + ); + return; + } + + return ( + ({ + color: theme.palette.tokens.TEXT_PRIMARY, + })} + > + {project.icuPlaceholders && ( + { + onChange('convertPlaceholdersToIcu', e.target.checked); + }} + data-cy={'import-convert-placeholders-to-icu-checkbox'} + hint={t('import_convert_placeholders_to_icu_checkbox_label_hint')} + label={t('import_convert_placeholders_to_icu_checkbox_label')} + checked={state?.convertPlaceholdersToIcu} + {...additionalCheckboxProps} + customHelpIcon={ + + + + } + /> + )} + { + onChange('overrideKeyDescriptions', e.target.checked); + }} + data-cy={'import-override-key-descriptions-checkbox'} + hint={t('import_override_key_descriptions_label_hint')} + label={t('import_override_key_descriptions_label')} + checked={state?.overrideKeyDescriptions} + customHelpIcon={ + + + + } + {...additionalCheckboxProps} + /> + + ); +}; + +const additionalCheckboxProps = { + labelInnerProps: { sx: { fontSize: '15px' } }, + labelProps: { sx: { marginRight: 0 } }, +}; + +const StyledLink = styled('a')` + color: ${({ theme }) => theme.palette.tokens.ICON_PRIMARY}; + + .icon { + font-size: 16px; + } +`; diff --git a/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx new file mode 100644 index 0000000000..591c46ce19 --- /dev/null +++ b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx @@ -0,0 +1,128 @@ +import { Box, styled, Typography } from '@mui/material'; +import { T } from '@tolgee/react'; +import React from 'react'; +import TolgeeLogo from 'tg.svgs/tolgeeLogo.svg?react'; +import IcuLogo from 'tg.svgs/logos/icu.svg?react'; +import PhoLogo from 'tg.svgs/logos/php.svg?react'; +import CLogo from 'tg.svgs/logos/c.svg?react'; +import PythonLogo from 'tg.svgs/logos/python.svg?react'; +import AppleLogo from 'tg.svgs/logos/apple.svg?react'; +import AndroidLogo from 'tg.svgs/logos/android.svg?react'; +import FluttrerLogo from 'tg.svgs/logos/flutter.svg?react'; + +const TechLogo = ({ + svg, + height, + width, +}: { + svg: React.ReactNode; + height?: string; + width?: string; +}) => { + return ( + ({ + color: theme.palette.tokens.TEXT_SECONDARY, + height: height || '20px', + width, + })} + > + {svg} + + ); +}; + +const FORMATS = [ + { + name: 'JSON', + logo: , + logoHeight: '24px', + logoWidth: '24px', + }, + { + name: 'XLIFF', + logo: , + }, + { name: 'PO PHP', logo: }, + { name: 'PO C/C++', logo: }, + { name: 'PO Python', logo: }, + { name: 'Apple Strings', logo: }, + { name: 'Apple Stringsdict', logo: }, + { name: 'Apple XLIFF', logo: }, + { name: 'Android XML', logo: }, + { name: 'Flutter ARB', logo: }, +]; + +export const ImportSupportedFormats = () => { + return ( + <> + ({ + color: theme.palette.tokens.TEXT_SECONDARY, + marginBottom: '16px', + marginTop: '16px', + textAlign: 'center', + })} + > + + + + {FORMATS.map((f) => ( + + ))} + + + ); +}; + +const Item = ({ + name, + logo, + logoHeight, + logoWidth, +}: { + name: string; + logo?: React.ReactNode; + logoHeight?: string; + logoWidth?: string; +}) => { + return ( + + + {name} + + ); +}; + +const StyledItem = styled('div')` + height: 36px; + display: inline-flex; + padding: 8px 12px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.palette.tokens.BORDER_SECONDARY}; + color: ${({ theme }) => theme.palette.tokens.TEXT_SECONDARY}; + background-color: ${({ theme }) => + theme.palette.tokens.SURFACE_BACKGROUND_SECONDARY}; + font-size: 15px; +`; + +const StyledContainer = styled('div')` + display: flex; + max-width: 795px; + justify-content: center; + align-items: center; + align-content: center; + gap: 8px; + flex-shrink: 0; + flex-wrap: wrap; +`; diff --git a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx index f813746d45..4cf9f2a794 100644 --- a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx +++ b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx @@ -17,12 +17,22 @@ import { components } from 'tg.service/apiSchema.generated'; import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; import { useApiQuery } from 'tg.service/http/useQueryApi'; import { StyledAppBar } from 'tg.component/layout/TopBar/TopBar'; +import { TranslationVisual } from 'tg.views/projects/translations/translationVisual/TranslationVisual'; const StyledTitle = styled(Typography)` margin-left: ${({ theme }) => theme.spacing(2)}; flex: 1; `; +const StyledDescription = styled('div')` + grid-area: description; + font-size: 13px; + color: ${({ theme }) => + theme.palette.mode === 'light' + ? theme.palette.emphasis[300] + : theme.palette.emphasis[500]}; +`; + const Transition = React.forwardRef(function Transition( props: TransitionProps & { children: React.ReactElement }, ref: React.Ref @@ -93,26 +103,37 @@ export const ImportTranslationsDialog: FunctionComponent<{ wrapperComponentProps={{ sx: { m: 2 } }} onPageChange={setPage} loadable={loadable} - renderItem={(i) => ( - - - - {i.keyName} - - - {i.text} + renderItem={(i) => { + return ( + + + + {i.keyName} + {i.keyDescription && ( + + {i.keyDescription} + + )} + + + + - - - )} + + ); + }} /> )} diff --git a/webapp/src/views/projects/import/component/LanguageSelector.tsx b/webapp/src/views/projects/import/component/LanguageSelector.tsx index 46ab369567..1738eadfab 100644 --- a/webapp/src/views/projects/import/component/LanguageSelector.tsx +++ b/webapp/src/views/projects/import/component/LanguageSelector.tsx @@ -48,14 +48,6 @@ export const LanguageSelector: React.FC<{ const importData = useImportDataHelper(); const languageHelper = useImportLanguageHelper(props.row); - const usedLanguages = - importData.result?._embedded?.languages - ?.map((l) => ({ - existingId: l.existingLanguageId, - namespace: l.namespace, - })) - .filter((l) => !!l) || []; - const state = useStateObject({ addNewLanguageDialogOpen: false }); const onChange = (changeEvent: any) => { @@ -67,17 +59,7 @@ export const LanguageSelector: React.FC<{ languageHelper.onSelectExisting(value); }; - const availableLanguages = languages.filter( - (lang) => - props.value == lang.id || - usedLanguages.findIndex( - (usedLanguage) => - usedLanguage.existingId === lang.id && - usedLanguage.namespace === props.row.namespace - ) < 0 - ); - - const items = availableLanguages.map((l) => ( + const items = languages.map((l) => ( {l.flagEmoji} {l.name} diff --git a/webapp/src/views/projects/import/hooks/useApplyImportHelper.tsx b/webapp/src/views/projects/import/hooks/useApplyImportHelper.tsx index 6c367ac3db..26fb17fdc6 100644 --- a/webapp/src/views/projects/import/hooks/useApplyImportHelper.tsx +++ b/webapp/src/views/projects/import/hooks/useApplyImportHelper.tsx @@ -7,6 +7,8 @@ import { useNdJsonStreamedMutation } from 'tg.service/http/useQueryApi'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { T } from '@tolgee/react'; import { OperationStatusType } from '../component/ImportFileInput'; +import { ApiError } from 'tg.service/http/ApiError'; +import { errorAction } from 'tg.service/http/errorAction'; export const useApplyImportHelper = ( dataHelper: ReturnType @@ -24,9 +26,14 @@ export const useApplyImportHelper = ( fetchOptions: { // error is displayed on the page disableErrorNotification: true, + disableAutoErrorHandle: true, }, onData(data) { - setStatus(data.status); + if (data.status == 'ERROR') { + errorAction(data.errorResponseBody.code); + throw new ApiError(data.errorResponseBody.code, data.errorResponseBody); + } + return setStatus(data.status); }, }); diff --git a/webapp/src/views/projects/import/hooks/useImportDataHelper.tsx b/webapp/src/views/projects/import/hooks/useImportDataHelper.tsx index bb5294c542..645398dbb9 100644 --- a/webapp/src/views/projects/import/hooks/useImportDataHelper.tsx +++ b/webapp/src/views/projects/import/hooks/useImportDataHelper.tsx @@ -5,6 +5,7 @@ import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; import { T } from '@tolgee/react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useEffect } from 'react'; +import { FilesType } from 'tg.fixtures/FileUploadFixtures'; type ResultType = components['schemas']['PagedModelImportLanguageModel']; @@ -95,7 +96,7 @@ export const useImportDataHelper = () => { }, }); - const onNewFiles = async (files: File[]) => { + const onNewFiles = async (files: FilesType) => { addFilesMutation.mutate({ path: { projectId: project.id, @@ -103,7 +104,9 @@ export const useImportDataHelper = () => { query: {}, content: { 'multipart/form-data': { - files: files as any, + files: files.map((f) => { + return new File([f.file], f.name, { type: f.file.type }); + }) as any, }, }, }); diff --git a/webapp/src/views/projects/languages/MachineTranslation/ServiceAvatar.tsx b/webapp/src/views/projects/languages/MachineTranslation/ServiceAvatar.tsx index c997f9a7fe..02c20ae262 100644 --- a/webapp/src/views/projects/languages/MachineTranslation/ServiceAvatar.tsx +++ b/webapp/src/views/projects/languages/MachineTranslation/ServiceAvatar.tsx @@ -2,7 +2,7 @@ import { Warning } from '@mui/icons-material'; import { Box, styled, Tooltip } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { useServiceImg } from 'tg.views/projects/translations/TranslationTools/useServiceImg'; +import { useServiceImg } from 'tg.views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg'; import { getServiceName } from './getServiceName'; import { ServiceType } from './types'; diff --git a/webapp/src/views/projects/languages/MachineTranslation/ServiceLabel.tsx b/webapp/src/views/projects/languages/MachineTranslation/ServiceLabel.tsx index cecf0cecd3..28f2422d51 100644 --- a/webapp/src/views/projects/languages/MachineTranslation/ServiceLabel.tsx +++ b/webapp/src/views/projects/languages/MachineTranslation/ServiceLabel.tsx @@ -1,7 +1,7 @@ import { Box, Tooltip } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { useServiceImg } from 'tg.views/projects/translations/TranslationTools/useServiceImg'; +import { useServiceImg } from 'tg.views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg'; import { getServiceName } from './getServiceName'; import { ServiceType } from './types'; diff --git a/webapp/src/views/projects/project/ProjectCreateView.tsx b/webapp/src/views/projects/project/ProjectCreateView.tsx index 4b1754aeb5..ea9bdda7ce 100644 --- a/webapp/src/views/projects/project/ProjectCreateView.tsx +++ b/webapp/src/views/projects/project/ProjectCreateView.tsx @@ -1,5 +1,5 @@ import { FunctionComponent } from 'react'; -import { Box, Grid, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { FormikProps } from 'formik'; import { useHistory } from 'react-router-dom'; @@ -19,7 +19,8 @@ import { BaseLanguageSelect } from './components/BaseLanguageSelect'; import { CreateProjectLanguagesArrayField } from './components/CreateProjectLanguagesArrayField'; import { useGlobalActions } from 'tg.globalContext/GlobalContext'; -export type CreateProjectValueType = components['schemas']['CreateProjectDTO']; +export type CreateProjectValueType = + components['schemas']['CreateProjectRequest']; export const ProjectCreateView: FunctionComponent = () => { const history = useHistory(); @@ -73,6 +74,7 @@ export const ProjectCreateView: FunctionComponent = () => { ], organizationId: preferredOrganization.id, baseLanguageTag: 'en', + icuPlaceholders: true, }; return ( @@ -90,19 +92,19 @@ export const ProjectCreateView: FunctionComponent = () => { > {(props: FormikProps) => { return ( - - - - } - name="name" - required={true} - /> - - + + + + + + + diff --git a/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx new file mode 100644 index 0000000000..8b63480ed6 --- /dev/null +++ b/webapp/src/views/projects/project/ProjectSettingsAdvanced.tsx @@ -0,0 +1,126 @@ +import { Box, Checkbox, FormControlLabel, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { DangerButton } from 'tg.component/DangerZone/DangerButton'; +import { DangerZone } from 'tg.component/DangerZone/DangerZone'; +import { ProjectTransferModal } from './components/ProjectTransferModal'; +import { useProject } from 'tg.hooks/useProject'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useState } from 'react'; +import { confirmation } from 'tg.hooks/confirmation'; +import { ConfirmationDialogProps } from 'tg.component/common/ConfirmationDialog'; +import { messageService } from 'tg.service/MessageService'; + +export const ProjectSettingsAdvanced = () => { + const project = useProject(); + const { t } = useTranslate(); + + const deleteLoadable = useApiMutation({ + url: '/v2/projects/{projectId}', + method: 'delete', + }); + + const updateLoadable = useApiMutation({ + url: '/v2/projects/{projectId}', + method: 'put', + invalidatePrefix: '/v2/projects', + }); + + const handleToggleTUIP = (value: boolean) => { + updateLoadable.mutate({ + path: { projectId: project.id }, + content: { + 'application/json': { + icuPlaceholders: value, + name: project.name, + description: project.description, + baseLanguageId: project.baseLanguage!.id, + }, + }, + }); + }; + + const [transferDialogOpen, setTransferDialogOpen] = useState(false); + const confirm = (options: ConfirmationDialogProps) => + confirmation({ + title: , + ...options, + }); + + const handleDelete = () => { + confirm({ + message: ( + + ), + onConfirm: () => + deleteLoadable.mutate( + { path: { projectId: project.id } }, + { + onSuccess() { + messageService.success(); + }, + } + ), + hardModeText: project.name.toUpperCase(), + }); + }; + return ( + <> + + handleToggleTUIP(val)} + /> + } + label={t('project_settings_use_tolgee_placeholders_label')} + data-cy="project-settings-use-tolgee-placeholders-checkbox" + /> + + {t('project_settings_tolgee_placeholders_hint')} + + + + + + + + + , + button: ( + + + + ), + }, + { + description: , + button: ( + setTransferDialogOpen(true)} + data-cy="project-settings-transfer-button" + > + + + ), + }, + ]} + /> + + setTransferDialogOpen(false)} + /> + + ); +}; diff --git a/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx b/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx new file mode 100644 index 0000000000..cd722a857d --- /dev/null +++ b/webapp/src/views/projects/project/ProjectSettingsGeneral.tsx @@ -0,0 +1,146 @@ +import { useProjectLanguages } from 'tg.hooks/useProjectLanguages'; +import { ProjectProfileAvatar } from './ProjectProfileAvatar'; +import { BaseLanguageSelect } from './components/BaseLanguageSelect'; +import { T } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProject } from 'tg.hooks/useProject'; +import { messageService } from 'tg.service/MessageService'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { useLeaveProject } from '../useLeaveProject'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { FieldLabel } from 'tg.component/FormField'; +import { Box, styled } from '@mui/material'; +import { ProjectLanguagesProvider } from 'tg.hooks/ProjectLanguagesProvider'; + +type FormValues = { + name: string; + description: string | undefined; + baseLanguageId: number | undefined; +}; + +const StyledContainer = styled('div')` + display: grid; + grid-template: 'fields avatar'; + grid-template-columns: 1fr auto; + gap: 24px; + margin-top: 24px; + + ${({ theme }) => theme.breakpoints.down('md')} { + grid-template: + 'avatar' + 'fields'; + grid-template-columns: 1fr; + } +`; + +const LanguageSelect = () => { + const projectLanguages = useProjectLanguages(); + return ( + } + name="baseLanguageId" + languages={projectLanguages} + /> + ); +}; + +export const ProjectSettingsGeneral = () => { + const project = useProject(); + const { leave, isLeaving } = useLeaveProject(); + + const initialValues = { + name: project.name, + baseLanguageId: project.baseLanguage?.id, + description: project.description ?? '', + } satisfies FormValues; + + const updateLoadable = useApiMutation({ + url: '/v2/projects/{projectId}', + method: 'put', + invalidatePrefix: '/v2/projects', + }); + + const handleEdit = (values: FormValues) => { + const data = { + ...values, + description: values.description || undefined, + }; + updateLoadable.mutate( + { + path: { projectId: project.id }, + content: { + 'application/json': { + ...data, + icuPlaceholders: project.icuPlaceholders, + }, + }, + }, + { + onSuccess() { + messageService.success( + + ); + }, + } + ); + }; + + return ( + + + + + leave(project.name, project.id)} + loading={isLeaving} + > + + + } + > + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/webapp/src/views/projects/project/ProjectSettingsView.tsx b/webapp/src/views/projects/project/ProjectSettingsView.tsx index f2a850b4d9..54afb0cf05 100644 --- a/webapp/src/views/projects/project/ProjectSettingsView.tsx +++ b/webapp/src/views/projects/project/ProjectSettingsView.tsx @@ -1,123 +1,35 @@ -import { FunctionComponent, useState } from 'react'; -import { Box, Typography } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { Redirect } from 'react-router-dom'; +import { FunctionComponent } from 'react'; +import { Box, Tab, Tabs, styled } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; +import { Link, useRouteMatch } from 'react-router-dom'; -import { ConfirmationDialogProps } from 'tg.component/common/ConfirmationDialog'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { Validation } from 'tg.constants/GlobalValidationSchema'; import { LINKS, PARAMS } from 'tg.constants/links'; -import { ProjectLanguagesProvider } from 'tg.hooks/ProjectLanguagesProvider'; -import { confirmation } from 'tg.hooks/confirmation'; import { useProject } from 'tg.hooks/useProject'; -import { useProjectLanguages } from 'tg.hooks/useProjectLanguages'; -import { components } from 'tg.service/apiSchema.generated'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { BaseLanguageSelect } from './components/BaseLanguageSelect'; -import { ProjectTransferModal } from 'tg.views/projects/project/components/ProjectTransferModal'; -import { DangerZone } from 'tg.component/DangerZone/DangerZone'; -import { ProjectProfileAvatar } from './ProjectProfileAvatar'; import { BaseProjectView } from '../BaseProjectView'; -import { useLeaveProject } from '../useLeaveProject'; -import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { DangerButton } from 'tg.component/DangerZone/DangerButton'; -import { messageService } from 'tg.service/MessageService'; +import { ProjectSettingsGeneral } from './ProjectSettingsGeneral'; +import { ProjectSettingsAdvanced } from './ProjectSettingsAdvanced'; -type ValueType = components['schemas']['EditProjectDTO']; +const StyledTabs = styled(Tabs)` + margin-bottom: -1px; +`; + +const StyledTabWrapper = styled(Box)` + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; +`; export const ProjectSettingsView: FunctionComponent = () => { const project = useProject(); - const updateLoadable = useApiMutation({ - url: '/v2/projects/{projectId}', - method: 'put', - invalidatePrefix: '/v2/projects', - }); - const deleteLoadable = useApiMutation({ - url: '/v2/projects/{projectId}', - method: 'delete', - }); - - const [transferDialogOpen, setTransferDialogOpen] = useState(false); - const confirm = (options: ConfirmationDialogProps) => - confirmation({ - title: , - ...options, - }); - - const handleEdit = (values: ValueType) => { - const data = { - ...values, - description: values.description || undefined, - }; - updateLoadable.mutate( - { - path: { projectId: project.id }, - content: { 'application/json': data }, - }, - { - onSuccess() { - messageService.success( - - ); - }, - } - ); - }; - - const handleDelete = () => { - confirm({ - message: ( - - ), - onConfirm: () => - deleteLoadable.mutate( - { path: { projectId: project.id } }, - { - onSuccess() { - messageService.success(); - }, - } - ), - hardModeText: project.name.toUpperCase(), - }); - }; - - const { leave, isLeaving } = useLeaveProject(); - const { t } = useTranslate(); - const initialValues: ValueType = { - name: project.name, - baseLanguageId: project.baseLanguage?.id, - description: project.description, - }; - - const [cancelled, setCancelled] = useState(false); - - if (cancelled || deleteLoadable.isSuccess) { - return ; - } - - const LanguageSelect = () => { - const projectLanguages = useProjectLanguages(); - return ( - } - name="baseLanguageId" - languages={projectLanguages} - /> - ); - }; + const pageGeneral = useRouteMatch(LINKS.PROJECT_EDIT.template); + const pageAdvanced = useRouteMatch(LINKS.PROJECT_EDIT_ADVANCED.template); return ( { ], ]} > - - - setCancelled(true)} - initialValues={initialValues} - customActions={ - leave(project.name, project.id)} - loading={isLeaving} - > - - + + - } - name="name" - required={true} - data-cy="project-settings-name" + - } - name="description" - data-cy="project-settings-description" + - - - - - - - - - - , - button: ( - - - - ), - }, - { - description: , - button: ( - setTransferDialogOpen(true)} - data-cy="project-settings-transfer-button" - > - - - ), - }, - ]} - /> + + - setTransferDialogOpen(false)} - /> + + {pageGeneral?.isExact && } + {pageAdvanced?.isExact && } ); diff --git a/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx b/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx index d421a8e2ae..363545798d 100644 --- a/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx +++ b/webapp/src/views/projects/project/components/BaseLanguageSelect.tsx @@ -1,10 +1,11 @@ import React, { FC, ReactNode, useEffect } from 'react'; -import { MenuItem } from '@mui/material'; +import { Box, MenuItem } from '@mui/material'; import { useFormikContext } from 'formik'; import { Select } from 'tg.component/common/form/fields/Select'; import { LanguageValue } from 'tg.component/languages/LanguageValue'; import { components } from 'tg.service/apiSchema.generated'; +import { FieldLabel } from 'tg.component/FormField'; export const BaseLanguageSelect: FC<{ languages: Partial[]; @@ -31,23 +32,26 @@ export const BaseLanguageSelect: FC<{ }, [value, availableLanguages]); return ( - + + {props.label} + + ); }; diff --git a/webapp/src/views/projects/translations/CellKey.tsx b/webapp/src/views/projects/translations/CellKey.tsx index 4e237bf072..7827615f06 100644 --- a/webapp/src/views/projects/translations/CellKey.tsx +++ b/webapp/src/views/projects/translations/CellKey.tsx @@ -1,31 +1,31 @@ +import clsx from 'clsx'; import React, { useRef, useState } from 'react'; +import { useDebounce } from 'use-debounce'; +import { useTranslate } from '@tolgee/react'; import { Checkbox, styled, Tooltip, Box } from '@mui/material'; -import clsx from 'clsx'; +import { Bolt } from '@mui/icons-material'; +import { LimitedHeightText } from 'tg.component/LimitedHeightText'; import { components } from 'tg.service/apiSchema.generated'; -import { LimitedHeightText } from './LimitedHeightText'; +import { stopBubble } from 'tg.fixtures/eventHandler'; + import { Tags } from './Tags/Tags'; -import { useEditableRow } from './useEditableRow'; import { ScreenshotsPopover } from './Screenshots/ScreenshotsPopover'; import { CELL_CLICKABLE, CELL_PLAIN, CELL_SELECTED, - PositionType, StyledCell, } from './cell/styles'; import { useTranslationsActions, useTranslationsSelector, } from './context/TranslationsContext'; -import { stopBubble } from 'tg.fixtures/eventHandler'; -import { useDebounce } from 'use-debounce'; import { ControlsKey } from './cell/ControlsKey'; import { TagAdd } from './Tags/TagAdd'; import { TagInput } from './Tags/TagInput'; import { KeyEditModal } from './KeyEdit/KeyEditModal'; -import { Bolt } from '@mui/icons-material'; -import { useTranslate } from '@tolgee/react'; +import { useKeyCell } from './useKeyCell'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; @@ -111,7 +111,6 @@ type Props = { editEnabled: boolean; active: boolean; simple?: boolean; - position?: PositionType; className?: string; onSaveSuccess?: (value: string) => void; editInDialog?: boolean; @@ -123,7 +122,6 @@ export const CellKey: React.FC = ({ editEnabled, active, simple, - position, onSaveSuccess, className, }) => { @@ -151,19 +149,14 @@ export const CellKey: React.FC = ({ const [tagEdit, setTagEdit] = useState(false); - const { isEditing, handleOpen, handleClose, editVal } = useEditableRow({ - keyId: data.keyId, - keyName: data.keyName, - defaultVal: data.keyName, - language: undefined, - onSaveSuccess, + const { isEditing, handleOpen, handleClose, editVal } = useKeyCell({ + keyData: data, cellRef, }); return ( <> = ({ className )} style={{ width }} - onClick={editEnabled ? () => handleOpen('editor') : undefined} + onClick={editEnabled ? () => handleOpen() : undefined} data-cy="translations-table-cell" tabIndex={0} ref={cellRef} @@ -241,7 +234,7 @@ export const CellKey: React.FC = ({ {!tagEdit ? ( active || screenshotsOpen || screenshotsOpenDebounced ? ( handleOpen('editor')} + onEdit={() => handleOpen()} onScreenshots={ simple ? undefined : () => setScreenshotsOpen(true) } @@ -277,14 +270,9 @@ export const CellKey: React.FC = ({ )} {isEditing && ( k.name)} - namespace={data.keyNamespace} + data={data} onClose={() => handleClose(true)} initialTab={editVal?.mode === 'context' ? 'context' : 'general'} - contextPresent={data.contextPresent} /> )} diff --git a/webapp/src/views/projects/translations/ColumnResizer.tsx b/webapp/src/views/projects/translations/ColumnResizer.tsx index 1d27162ce7..acf6ffe78f 100644 --- a/webapp/src/views/projects/translations/ColumnResizer.tsx +++ b/webapp/src/views/projects/translations/ColumnResizer.tsx @@ -12,9 +12,9 @@ const StyledDraggableContent = styled('div')` justify-content: center; & .expanded { - width: 34px; - margin-left: -15px; - margin-right: -15px; + width: 2004px; + margin-left: -1000px; + margin-right: -1000px; } `; @@ -64,11 +64,13 @@ export const ColumnResizer: React.FC = ({ onDrag={(e, data) => { setPosition({ x: data.x, y: 0 }); }} - onStop={() => { + onStop={(e) => { + e.preventDefault(); + e.stopPropagation(); setIsDragging(false); setPosition({ x: 0, y: 0 }); }} - bounds="parent" + bounds={false} > diff --git a/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx b/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx index 766f278b95..0f8ebb4cfa 100644 --- a/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx +++ b/webapp/src/views/projects/translations/Filters/FiltersMenu.tsx @@ -1,9 +1,9 @@ import { Menu, MenuProps } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; +import { CompactMenuItem } from 'tg.component/ListComponents'; import { useTranslationsActions } from '../context/TranslationsContext'; -import { CompactMenuItem } from './FiltersComponents'; import { useActiveFilters } from './useActiveFilters'; import { useFiltersContent } from './useFiltersContent'; diff --git a/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx b/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx index b95094f9c0..d9946d044b 100644 --- a/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx +++ b/webapp/src/views/projects/translations/Filters/SubmenuMulti.tsx @@ -3,8 +3,8 @@ import { ListItemText, Popover } from '@mui/material'; import { ArrowRight } from '@mui/icons-material'; import { OptionType } from './tools'; -import { CompactMenuItem } from './FiltersComponents'; import { SearchSelectMulti } from '../../../../component/searchSelect/SearchSelectMulti'; +import { CompactMenuItem } from 'tg.component/ListComponents'; type Props = { item: OptionType; diff --git a/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx b/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx index 5ba21f7aba..03ad4c5c06 100644 --- a/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx +++ b/webapp/src/views/projects/translations/Filters/SubmenuStates.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Checkbox, ListItemText, Menu, MenuItem, styled } from '@mui/material'; import { ArrowRight } from '@mui/icons-material'; import { TRANSLATION_STATES } from 'tg.constants/translationStates'; import { decodeFilter, OptionType } from './tools'; -import { CompactMenuItem } from './FiltersComponents'; +import { CompactMenuItem } from 'tg.component/ListComponents'; const StyledDot = styled('div')` width: 8px; diff --git a/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx b/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx index e63c5e7c8d..45d0034d50 100644 --- a/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx +++ b/webapp/src/views/projects/translations/Filters/useFiltersContent.tsx @@ -6,10 +6,14 @@ import { } from '../context/TranslationsContext'; import { SubmenuStates } from './SubmenuStates'; import { SubmenuMulti } from './SubmenuMulti'; -import { CompactListSubheader, CompactMenuItem } from './FiltersComponents'; import { useAvailableFilters } from './useAvailableFilters'; import { toggleFilter } from './tools'; import { useActiveFilters } from './useActiveFilters'; +import { + CompactListSubheader, + CompactMenuItem, +} from 'tg.component/ListComponents'; +import React from 'react'; export const useFiltersContent = () => { const options: any[] = []; diff --git a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx index 0a8b8e4b32..759e29050b 100644 --- a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx +++ b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx @@ -1,27 +1,31 @@ -import { useCallback, useEffect, useState } from 'react'; -import { FastField, FieldArray, FieldProps, useFormikContext } from 'formik'; +import { + FastField, + Field, + FieldArray, + FieldProps, + useFormikContext, +} from 'formik'; import { Box, Button, styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; -import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; import { NamespaceSelector } from 'tg.component/NamespaceSelector/NamespaceSelector'; import { EditorWrapper } from 'tg.component/editor/EditorWrapper'; import { FieldError } from 'tg.component/FormField'; -import { components } from 'tg.service/apiSchema.generated'; import { Editor } from 'tg.component/editor/Editor'; import { useProject } from 'tg.hooks/useProject'; import { FieldLabel } from 'tg.component/FormField'; import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { LabelHint } from 'tg.component/common/LabelHint'; import { Tag } from '../Tags/Tag'; import { TagInput } from '../Tags/TagInput'; -import { ToolsBottomPanel } from '../TranslationTools/ToolsBottomPanel'; -import { useTranslationTools } from '../TranslationTools/useTranslationTools'; import { RequiredField } from 'tg.component/common/form/RequiredField'; import { CircledLanguageIcon } from 'tg.component/languages/CircledLanguageIcon'; -import { LabelHint } from 'tg.component/common/LabelHint'; - -type LanguageModel = components['schemas']['LanguageModel']; +import { PluralEditor } from '../translationVisual/PluralEditor'; +import type { ValuesCreateType } from './KeyCreateForm'; +import { PluralFormCheckbox } from 'tg.component/common/form/PluralFormCheckbox'; +import { ControlsEditorSmall } from '../cell/ControlsEditorSmall'; +import { useState } from 'react'; const StyledContainer = styled('div')` display: grid; @@ -60,59 +64,22 @@ const StyledTags = styled('div')` type Props = { onCancel?: () => void; autofocus?: boolean; - languages: LanguageModel[]; }; -export const FormBody: React.FC = ({ - onCancel, - autofocus, - languages, -}) => { - const [editedLang, setEditedLang] = useState(null); +export const FormBody: React.FC = ({ onCancel, autofocus }) => { const { t } = useTranslate(); - const form = useFormikContext(); + const form = useFormikContext(); const project = useProject(); - const onFocus = (lang: string | null) => { - setEditedLang(lang); - }; - - const baseLang = project.baseLanguage?.tag; - - const baseText = form.values?.translations?.[baseLang || '']; - const targetLang = languages.find(({ tag }) => tag === editedLang); - - const hintRelevant = Boolean( - baseText && targetLang && editedLang !== baseLang - ); - - const [hintDisplayed, setHintDisplayed] = useState(false); + const baseLang = project.baseLanguage!; - const onValueUpdate = useCallback( - (value: string) => { - form.setFieldValue(`translations.${editedLang}`, value); - }, - [editedLang] - ); + const isPlural = form.values.isPlural; - useEffect(() => { - if (hintRelevant) { - if (!hintDisplayed) { - setHintDisplayed(true); - } - } else if (!baseText) { - setHintDisplayed(false); - } - }, [hintRelevant, baseText]); + const [mode, setMode] = useState<'placeholders' | 'syntax'>('placeholders'); - const toolsData = useTranslationTools({ - projectId: project.id, - baseText, - targetLanguageId: targetLang?.id as number, - keyId: undefined as any, - onValueUpdate, - enabled: hintRelevant, - }); + const actualParameter = isPlural + ? form.values.pluralParameter || 'value' + : undefined; return ( <> @@ -130,17 +97,22 @@ export const FormBody: React.FC = ({ { form.setFieldValue(field.name, val); }} - onSave={() => form.handleSubmit()} onBlur={() => form.setFieldTouched(field.name, true)} minHeight="unset" autofocus={autofocus} scrollMargins={{ bottom: 150 }} autoScrollIntoView + shortcuts={[ + { + key: 'Enter', + run: () => (form.handleSubmit(), true), + }, + ]} /> @@ -184,12 +156,17 @@ export const FormBody: React.FC = ({ { form.setFieldValue(field.name, val); }} - onSave={() => form.handleSubmit()} + shortcuts={[ + { + key: 'Enter', + run: () => (form.handleSubmit(), true), + }, + ]} onBlur={() => form.setFieldTouched(field.name, true)} minHeight={50} scrollMargins={{ bottom: 150 }} @@ -236,40 +213,52 @@ export const FormBody: React.FC = ({ )} /> - + - {languages.map((lang, i) => ( - - {({ field, form, meta }) => ( -
+ + {({ field, meta }) => ( +
+ - - {lang.name} + + {baseLang.name} - - - form.handleSubmit()} - onChange={(val) => { - form.setFieldValue(field.name, val); - }} - direction={getLanguageDirection(lang.tag)} - onFocus={() => onFocus(lang.tag)} - minHeight={50} - scrollMargins={{ bottom: 150 }} - autoScrollIntoView - /> - - - -
- )} - - ))} + + setMode(mode === 'syntax' ? 'placeholders' : 'syntax') + } + /> + + { + form.setFieldValue(field.name, val); + }} + locale={baseLang.tag} + mode={mode} + editorProps={{ + autoScrollIntoView: true, + scrollMargins: { bottom: 150 }, + shortcuts: [ + { + key: 'Enter', + run: () => (form.handleSubmit(), true), + }, + ], + }} + /> + +
+ )} +
+ {onCancel && (
- {canViewScreenshots && (
- + - - - + + {selectedLanguages?.map((lang) => { + const language = languages?.find((l) => l.tag === lang); + return language ? ( + + + + ) : null; + })} +
- )} - - {editEnabled && ( - + {canViewScreenshots && ( +
+ + + + + + +
)} -
-
+ + + {editEnabled && ( + + )} + + + {toolsPanelOpen && ( + + + + )} +
) : null; }; diff --git a/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx b/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx index 1c67e6cc8b..c871e10884 100644 --- a/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx +++ b/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx @@ -90,18 +90,21 @@ export const KeySingle: React.FC = ({ keyName, keyId }) => { ]} > - - l.tag)} - context="languages" - /> - {keyExists ? ( - + <> + + l.tag)} + context="languages" + /> + + + ) : ( { // reload translations as new one was created invalidateUrlPrefix( @@ -118,7 +121,6 @@ export const KeySingle: React.FC = ({ keyName, keyId }) => { }) ); }} - languages={selectedLanguagesMapped} onCancel={() => history.push( LINKS.PROJECT_TRANSLATIONS.build({ diff --git a/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx b/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx new file mode 100644 index 0000000000..6c655aa51e --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/FloatingToolsPanel.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { styled } from '@mui/material'; + +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { ToolsPanel } from './ToolsPanel'; +import { useHeaderNsContext } from '../context/HeaderNsContext'; + +const StyledContainer = styled('div')` + position: relative; + position: sticky; + box-sizing: border-box; + width: 24vw; + min-width: 300px; + height: 800px; + border: 1px solid ${({ theme }) => theme.palette.divider1}; + border-bottom: 0px; + border-right: 0px; + margin-top: 10px; + transition: top 0.2s ease-in-out; + margin-bottom: -20px; + overflow-y: auto; + overflow-x: hidden; +`; + +export const FloatingToolsPanel = () => { + const containerRef = useRef(null); + const topBannerHeight = useGlobalContext((c) => c.topBannerHeight); + const topBarHeight = useGlobalContext((c) => c.topBarHeight); + const keyId = useTranslationsSelector((c) => c.cursor?.keyId); + const languageTag = useTranslationsSelector((c) => c.cursor?.language); + const languages = useTranslationsSelector((c) => c.languages); + const [fixedTopDistance, setFixedTopDistance] = useState(0); + + useEffect(() => { + function recalculate() { + const position = containerRef.current?.getBoundingClientRect(); + setFixedTopDistance(position?.top ?? 0); + } + + recalculate(); + addEventListener('scroll', recalculate); + addEventListener('resize', recalculate); + const interval = setInterval(recalculate, 500); + + return () => { + removeEventListener('scroll', recalculate); + removeEventListener('resize', recalculate); + clearInterval(interval); + }; + }, [topBarHeight]); + + const language = useMemo(() => { + return languages?.find((l) => l.tag === languageTag); + }, [languageTag, languages]); + + const floatingBannerHeight = useHeaderNsContext( + (c) => c?.floatingBannerHeight ?? 0 + ); + + return ( + + + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/ToolsPanel.tsx b/webapp/src/views/projects/translations/ToolsPanel/ToolsPanel.tsx new file mode 100644 index 0000000000..8d9bda9235 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/ToolsPanel.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { Box, IconButton, Typography, styled } from '@mui/material'; +import { useProject } from 'tg.hooks/useProject'; + +import { + useTranslationsActions, + useTranslationsSelector, +} from '../context/TranslationsContext'; +import { Panel } from './common/Panel'; + +import { PANELS, PANELS_WHEN_INACTIVE } from './panelsList'; +import { useOpenPanels } from './useOpenPanels'; +import { Close } from '@mui/icons-material'; +import { T } from '@tolgee/react'; +import { isElementInput } from 'tg.fixtures/isElementInput'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; + +const StyledButton = styled(IconButton)` + position: absolute; + top: 0px; + right: 0px; +`; + +const StyledTitle = styled(Box)` + margin-top: 6px; + margin-left: 8px; +`; + +const StyledWrapper = styled('div')` + display: grid; + padding: 8px 0px 8px 8px; + padding-bottom: 100px; +`; + +const StyledPanelList = styled(Box)` + display: grid; + position: relative; +`; + +export const ToolsPanel = () => { + const project = useProject(); + const keyId = useTranslationsSelector((c) => c.cursor?.keyId); + const languageTag = useTranslationsSelector((c) => c.cursor?.language); + const activeVariant = useTranslationsSelector((c) => c.cursor?.activeVariant); + const translations = useTranslationsSelector((c) => c.translations); + const languages = useTranslationsSelector((c) => c.languages); + const { setEditValueString, setSidePanelOpen } = useTranslationsActions(); + + const [openPanels, setOpenPanels] = useOpenPanels(); + + const keyData = useMemo(() => { + return translations?.find((t) => t.keyId === keyId); + }, [keyId, translations]); + + const language = useMemo(() => { + return languages?.find((l) => l.tag === languageTag); + }, [languageTag, languages]); + + const baseLanguage = useMemo(() => { + return languages?.find((l) => l.base); + }, [languages]); + const translation = language?.tag + ? keyData?.translations[language.tag] + : undefined; + + const displayPanels = keyData && language && baseLanguage; + const { satisfiesLanguageAccess } = useProjectPermissions(); + + const dataProps = { + project, + keyData: keyData!, + language: language!, + baseLanguage: baseLanguage!, + activeVariant: activeVariant!, + setValue: setEditValueString, + editEnabled: language + ? satisfiesLanguageAccess('translations.edit', language.id) && + translation?.state !== 'DISABLED' + : false, + }; + + return ( + { + if (!isElementInput(e.target as Element)) { + e.preventDefault(); + } + }} + > + {displayPanels ? ( + + {PANELS.filter( + ({ displayPanel }) => !displayPanel || displayPanel(dataProps) + ).map((config) => ( + { + if (openPanels.includes(config.id)) { + setOpenPanels(openPanels.filter((i) => i !== config.id)); + } else { + setOpenPanels([...openPanels, config.id]); + } + }} + open={openPanels.includes(config.id)} + /> + ))} + + ) : ( + + + + + + + setSidePanelOpen(false)}> + + + {PANELS_WHEN_INACTIVE.map((config) => ( + { + if (openPanels.includes(config.id)) { + setOpenPanels(openPanels.filter((i) => i !== config.id)); + } else { + setOpenPanels([...openPanels, config.id]); + } + }} + open={openPanels.includes(config.id)} + /> + ))} + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx new file mode 100644 index 0000000000..5beaba8212 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/Panel.tsx @@ -0,0 +1,114 @@ +import { Box, styled } from '@mui/material'; +import { PanelConfig, PanelContentProps } from './types'; +import { useState } from 'react'; +import { KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; + +const StyledContainer = styled(Box)` + display: grid; +`; + +const StyledHeader = styled(Box)` + max-width: 100%; + position: sticky; + display: grid; + grid-template-columns: auto auto auto 1fr; + top: -1px; + padding: 8px; + gap: 8px; + align-items: center; + background: ${({ theme }) => theme.palette.background.default}; + z-index: 3; + height: 39px; +`; + +const StyledName = styled(Box)` + flex-shrink: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + text-transform: uppercase; + font-weight: 500; +`; + +const StyledBadge = styled(Box)` + padding: 2px 4px; + border-radius: 12px; + font-size: 12px; + height: 20px; + min-width: 20px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.palette.emphasis[100]}; +`; + +const StyledContent = styled(Box)` + min-height: 60px; + padding-top: 0px; + padding-bottom: 16px; +`; + +const StyledToggle = styled(Box)` + display: grid; + cursor: pointer; +`; + +type Props = PanelConfig & { + data: Omit; + onToggle: () => void; + open: boolean; +}; + +export const Panel = ({ + id, + icon, + name, + component, + data, + itemsCountComponent, + onToggle, + open, +}: Props) => { + const [itemsCount, setItemsCount] = useState(undefined); + const Component = component; + const ItemsCountComponent = itemsCountComponent; + + return ( + + + {icon} + {name} + {typeof itemsCount === 'number' || ItemsCountComponent ? ( + + {ItemsCountComponent ? ( + + ) : ( + itemsCount + )} + + ) : ( +
+ )} + onToggle()} + data-cy="translation-panel-toggle" + data-cy-id={id} + > + {open ? ( + + ) : ( + + )} + + + {open && ( + + + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/SmallActionButton.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/SmallActionButton.tsx new file mode 100644 index 0000000000..91346d5060 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/SmallActionButton.tsx @@ -0,0 +1,33 @@ +import { styled } from '@mui/material'; + +type Props = React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>; + +const StyledButton = styled('button')` + margin: 0px; + outline: 0; + font-size: 12px; + padding: 0px; + border: 0px; + opacity: 0.6; + background: transparent; + cursor: pointer; + &:hover, + &:active { + opacity: 1; + } +`; + +export const SmallActionButton: React.FC = ({ + children, + className, + ...tools +}) => { + return ( + + {children} + + ); +}; diff --git a/webapp/src/component/common/StickyDateSeparator.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/StickyDateSeparator.tsx similarity index 61% rename from webapp/src/component/common/StickyDateSeparator.tsx rename to webapp/src/views/projects/translations/ToolsPanel/common/StickyDateSeparator.tsx index 184147d5cb..75aa831d5f 100644 --- a/webapp/src/component/common/StickyDateSeparator.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/common/StickyDateSeparator.tsx @@ -1,33 +1,25 @@ -import { Box, styled } from '@mui/material'; +import { styled } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { useCurrentLanguage } from 'tg.hooks/useCurrentLanguage'; -const StyledContainer = styled('div')` +const StyledStickyContainer = styled('div')` display: flex; align-items: start; + justify-self: start; position: sticky; - top: 0px; - height: 25px; - background: ${({ theme }) => theme.palette.cell.inside}; - padding-bottom: 1px; - padding-top: 1px; + top: 38px; + background: ${({ theme }) => theme.palette.background.default}; + margin-left: 8px; z-index: 1; - & > * { - margin-top: -2px; - } -`; - -const StyledLine = styled(Box)` - height: 1px; - background: ${({ theme }) => theme.palette.divider}; - flex-grow: 1; + border-radius: 0px 0px 12px 12px; `; const StyledDate = styled('div')` border: 1px solid ${({ theme }) => theme.palette.divider}; - border-radius: 0px 0px 12px 12px; + border-radius: 12px; padding: 0px 10px; font-size: 14px; + background: ${({ theme }) => theme.palette.background.default}; `; type Props = { @@ -40,13 +32,11 @@ export const StickyDateSeparator: React.FC = ({ date }) => { const { t } = useTranslate(); return ( - - + {isToday && `${t('activity_date_today')} `} {date.toLocaleDateString(lang)} - - + ); }; diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/StyledLoadMore.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/StyledLoadMore.tsx new file mode 100644 index 0000000000..e42879d5bf --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/StyledLoadMore.tsx @@ -0,0 +1,18 @@ +import { styled } from '@mui/material'; + +export const StyledLoadMore = styled('div')` + display: flex; + justify-content: center; + align-items: flex-end; + z-index: 2; + padding-top: 8px; + padding-bottom: 2px; +`; + +export const StyledLoadMoreButton = styled('div')` + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-transform: uppercase; + color: ${({ theme }) => theme.palette.text.secondary}; +`; diff --git a/webapp/src/views/projects/translations/TranslationTools/TabMessage.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/TabMessage.tsx similarity index 80% rename from webapp/src/views/projects/translations/TranslationTools/TabMessage.tsx rename to webapp/src/views/projects/translations/ToolsPanel/common/TabMessage.tsx index ed6913b3ea..84f8978f67 100644 --- a/webapp/src/views/projects/translations/TranslationTools/TabMessage.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/common/TabMessage.tsx @@ -1,8 +1,9 @@ import { styled } from '@mui/material'; const StyledWrapper = styled('div')` - padding: ${({ theme }) => theme.spacing(1, 1.25)}; + margin: ${({ theme }) => theme.spacing(1, 1.25)}; color: ${({ theme }) => theme.palette.text.disabled}; + font-style: italic; `; type Props = { diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/splitByParameter.ts b/webapp/src/views/projects/translations/ToolsPanel/common/splitByParameter.ts new file mode 100644 index 0000000000..a249a945c0 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/splitByParameter.ts @@ -0,0 +1,13 @@ +export const arraySplit = (array: T[], callback: (val: T) => any) => { + const groups: T[][] = []; + let lastResult: any = undefined; + array.forEach((i) => { + const result = callback(i); + if (result !== lastResult || groups.length === 0) { + groups.push([]); + } + groups[groups.length - 1].push(i); + lastResult = result; + }); + return groups; +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/types.ts b/webapp/src/views/projects/translations/ToolsPanel/common/types.ts new file mode 100644 index 0000000000..8f0616f887 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/types.ts @@ -0,0 +1,30 @@ +import { components } from 'tg.service/apiSchema.generated'; +import { DeletableKeyWithTranslationsModelType } from '../../context/types'; +import { LanguageModel } from 'tg.component/PermissionsSettings/types'; + +export type ProjectModel = components['schemas']['ProjectModel']; +export type TranslationViewModel = + components['schemas']['TranslationViewModel']; + +export type PanelContentData = { + project: ProjectModel; + keyData: DeletableKeyWithTranslationsModelType; + language: LanguageModel; + baseLanguage: LanguageModel; + activeVariant: string | undefined; + editEnabled: boolean; +}; + +export type PanelContentProps = PanelContentData & { + setItemsCount: (value: number | undefined) => void; + setValue: (value: string) => void; +}; + +export type PanelConfig = { + id: string; + icon: React.ReactNode; + name: React.ReactNode; + component: React.FC; + itemsCountComponent?: React.FC; + displayPanel?: (value: PanelContentData) => boolean; +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/common/useExtractedPlural.tsx b/webapp/src/views/projects/translations/ToolsPanel/common/useExtractedPlural.tsx new file mode 100644 index 0000000000..cc32f71d70 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/common/useExtractedPlural.tsx @@ -0,0 +1,45 @@ +import { getTolgeePlurals, getVariantExample } from '@tginternal/editor'; +import { useMemo } from 'react'; +import { useProject } from 'tg.hooks/useProject'; + +export const useExtractedPlural = ( + variant: string | undefined, + text: string | undefined +) => { + const project = useProject(); + return useMemo(() => { + if (variant && text) { + const plurals = getTolgeePlurals(text, !project.icuPlaceholders); + if (plurals.parameter) { + return plurals.variants[variant] || ''; + } + } + return text; + }, [variant, text]); +}; + +export const useVariantExample = ( + variant: string | undefined, + language: string +) => { + return useMemo(() => { + if (!variant) { + return undefined; + } + return getVariantExample(language, variant); + }, [variant, language]); +}; + +export const useBaseVariant = ( + variant: string | undefined, + language: string, + baseLanguage: string +) => { + const example = useVariantExample(variant, language); + + return useMemo(() => { + if (example) { + return new Intl.PluralRules(baseLanguage).select(example); + } + }, [baseLanguage, example]); +}; diff --git a/webapp/src/views/projects/translations/comments/Comment.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comment.tsx similarity index 96% rename from webapp/src/views/projects/translations/comments/Comment.tsx rename to webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comment.tsx index 6be398a931..d3c238876f 100644 --- a/webapp/src/views/projects/translations/comments/Comment.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comment.tsx @@ -7,9 +7,9 @@ import { Check, MoreVert } from '@mui/icons-material'; import { components } from 'tg.service/apiSchema.generated'; import { confirmation } from 'tg.hooks/confirmation'; import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; -import { SmallActionButton } from '../cell/SmallActionButton'; import { UserName } from 'tg.component/common/UserName'; import { useCurrentLanguage } from 'tg.hooks/useCurrentLanguage'; +import { SmallActionButton } from '../../common/SmallActionButton'; type TranslationCommentModel = components['schemas']['TranslationCommentModel']; @@ -18,7 +18,7 @@ const StyledContainer = styled('div')` grid-template-areas: 'avatar text time menu' 'avatar text resolveAction menu'; - grid-template-columns: auto 1fr auto 26px; + grid-template-columns: auto 1fr auto auto; padding: 4px 9px 4px 9px; margin: 3px; background: transparent; @@ -80,8 +80,7 @@ const StyledTextPre = styled('pre')` word-wrap: break-word; &.textUnresolved { - color: ${({ theme }) => theme.palette.primary.main}; - font-weight: 500; + font-weight: 700; } `; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comments.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comments.tsx new file mode 100644 index 0000000000..bd5a5f3cee --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/Comments.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useState } from 'react'; +import { T } from '@tolgee/react'; +import { Box, IconButton, styled, TextField } from '@mui/material'; +import { Send } from '@mui/icons-material'; + +import { useUser } from 'tg.globalContext/helpers'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; +import { Comment } from './Comment'; +import { useComments } from './useComments'; +import { StickyDateSeparator } from 'tg.views/projects/translations/ToolsPanel/common/StickyDateSeparator'; +import { + PanelContentData, + PanelContentProps, + TranslationViewModel, +} from '../../common/types'; +import { TabMessage } from '../../common/TabMessage'; +import clsx from 'clsx'; +import { + StyledLoadMore, + StyledLoadMoreButton, +} from '../../common/StyledLoadMore'; +import { arraySplit } from '../../common/splitByParameter'; + +const StyledContainer = styled('div')` + display: grid; + display: flex; + flex-direction: column; + align-items: stretch; + margin-top: 4px; +`; + +const StyledTextField = styled(TextField)` + flex-grow: 1; + margin: 8px; + opacity: 0.5; + &:focus-within { + opacity: 1; + } + &:focus-within .icon-button { + color: ${({ theme }) => theme.palette.primary.main}; + } +`; + +export const Comments: React.FC = ({ + keyData, + language, + setItemsCount, +}) => { + const { satisfiesPermission } = useProjectPermissions(); + const user = useUser(); + const [limit, setLimit] = useState(true); + + const canAddComment = satisfiesPermission('translation-comments.add'); + const canEditComment = satisfiesPermission('translation-comments.edit'); + const canSetCommentState = satisfiesPermission( + 'translation-comments.set-state' + ); + + const keyId = keyData.keyId; + const translation = keyData.translations[language.tag] as + | TranslationViewModel + | undefined; + + const { + commentsList, + comments, + handleAddComment, + handleDelete, + handleKeyDown, + changeState, + isAddingComment, + inputValue, + setInputValue, + fetchMore, + } = useComments({ + keyId, + language, + translation, + }); + + useEffect(() => { + setItemsCount(translation?.commentCount); + }, [translation?.commentCount]); + + const showLoadMore = + comments.hasNextPage || (limit && commentsList.length > 4); + + function handleShowMore() { + if (limit) { + setLimit(false); + } else { + fetchMore(); + } + } + + const trimmedComments = limit ? commentsList.slice(-4) : commentsList; + + const dayGroups = arraySplit(trimmedComments, (i) => + new Date(i.createdAt).toLocaleDateString() + ); + + return ( + + {dayGroups.length !== 0 ? ( + dayGroups.map((items, gIndex) => ( + + + {items?.map((comment, cIndex) => { + const canDelete = + user?.id === comment.author.id || canEditComment; + return ( + + {showLoadMore && cIndex + gIndex === 0 && ( + + + + + + )} + + + ); + })} + + )) + ) : ( + + + + )} + + {canAddComment && ( + setInputValue(e.currentTarget.value)} + onKeyDown={handleKeyDown} + data-cy="translations-comments-input" + InputProps={{ + sx: { + padding: '8px 4px 8px 12px', + borderRadius: '8px', + }, + endAdornment: ( + e.preventDefault()} + onClick={handleAddComment} + disabled={isAddingComment} + sx={{ my: '-7px', alignSelf: 'end' }} + > + + + ), + }} + /> + )} + + ); +}; + +export const CommentsItemsCount = ({ keyData, language }: PanelContentData) => { + const translation = keyData.translations[language.tag] as + | TranslationViewModel + | undefined; + return <>{translation?.commentCount ?? 0}; +}; diff --git a/webapp/src/views/projects/translations/comments/useComments.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/useComments.tsx similarity index 88% rename from webapp/src/views/projects/translations/comments/useComments.tsx rename to webapp/src/views/projects/translations/ToolsPanel/panels/Comments/useComments.tsx index 69eac23315..7136147a41 100644 --- a/webapp/src/views/projects/translations/comments/useComments.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/Comments/useComments.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState } from 'react'; import { useQueryClient } from 'react-query'; import { T } from '@tolgee/react'; @@ -9,8 +9,8 @@ import { useApiMutation, } from 'tg.service/http/useQueryApi'; -import { useTranslationsActions } from '../context/TranslationsContext'; import { messageService } from 'tg.service/MessageService'; +import { useTranslationsActions } from 'tg.views/projects/translations/context/TranslationsContext'; type TranslationCommentModel = components['schemas']['TranslationCommentModel']; type PagedModelTranslationCommentModel = @@ -22,17 +22,10 @@ type Props = { keyId: number; translation: TranslationViewModel | undefined; language: LanguageModel; - onCancel: () => void; }; -export const useComments = ({ - keyId, - translation, - language, - onCancel, -}: Props) => { +export const useComments = ({ keyId, translation, language }: Props) => { const project = useProject(); - const scrollRef = useRef(null); const queryClient = useQueryClient(); const { updateTranslation } = useTranslationsActions(); @@ -43,7 +36,7 @@ export const useComments = ({ projectId: project.id, translationId: translation?.id as number, }; - const query = { sort: ['createdAt,desc', 'id,desc'], size: 30, page: 0 }; + const query = { sort: ['createdAt,desc', 'id,desc'], size: 20, page: 0 }; const comments = useApiInfiniteQuery({ url: '/v2/projects/{projectId}/translations/{translationId}/comments', @@ -122,7 +115,6 @@ export const useComments = ({ handleAddComment(); e.preventDefault(); } else if (e.key === 'Escape') { - onCancel(); e.preventDefault(); } } @@ -241,25 +233,13 @@ export const useComments = ({ ); commentsList.reverse(); - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [comments.data?.pages?.[0].page?.totalElements]); - const fetchMore = () => { - const previousHeight = Number(scrollRef.current?.scrollHeight); - comments.fetchNextPage().then(() => { - const newHeight = Number(scrollRef.current?.scrollHeight); - scrollRef.current?.scrollTo({ - // persist scrolling position - top: newHeight - previousHeight, - }); - }); + comments.fetchNextPage(); }; return { inputValue, setInputValue, - scrollRef, comments, fetchMore, handleKeyDown, diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/History/History.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/History/History.tsx new file mode 100644 index 0000000000..cb27879a36 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/History/History.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { T } from '@tolgee/react'; +import { Box, FormControlLabel, styled, Switch } from '@mui/material'; + +import { StickyDateSeparator } from 'tg.views/projects/translations/ToolsPanel/common/StickyDateSeparator'; +import { HistoryItem } from './HistoryItem'; +import { PanelContentProps } from '../../common/types'; +import { TabMessage } from '../../common/TabMessage'; +import { useHistory } from './useHistory'; +import { + StyledLoadMore, + StyledLoadMoreButton, +} from '../../common/StyledLoadMore'; +import { arraySplit } from '../../common/splitByParameter'; + +const StyledContainer = styled('div')` + display: flex; + flex-direction: column; + flex-grow: 1; + flex-basis: 100px; + position: relative; + margin-top: 4px; +`; + +const StyledDifferenceToggle = styled(FormControlLabel)` + position: absolute; + right: 24px; + top: 2px; + z-index: 2; + display: flex; + align-items: start; + & > span { + font-size: 14px; + } +`; + +export const History: React.FC = ({ keyData, language }) => { + const translation = keyData.translations[language.tag]; + const [limit, setLimit] = useState(true); + + const { fetchMore, historyItems, ...history } = useHistory({ + keyId: keyData.keyId, + translation, + language, + }); + + function handleShowMore() { + if (limit) { + setLimit(false); + } else { + fetchMore(); + } + } + + const [showdifferences, setShowDifferences] = useState(true); + const toggleDifferences = () => setShowDifferences((val) => !val); + + const trimmedHistory = limit ? historyItems.slice(-4) : historyItems; + const showLoadMore = + history.hasNextPage || (limit && historyItems.length > 4); + + if (!trimmedHistory.length) { + return ( + + + + + + ); + } + + const dayGroups = arraySplit(trimmedHistory, (i) => + new Date(i.timestamp).toLocaleDateString() + ); + + return ( + + } + labelPlacement="start" + control={ + + } + /> + {dayGroups.map((items, gIndex) => ( + + + {items?.map((entry, cIndex) => { + return ( + + {showLoadMore && cIndex + gIndex === 0 && ( + + + + + + )} + + + ); + })} + + ))} + + ); +}; diff --git a/webapp/src/views/projects/translations/history/HistoryItem.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/History/HistoryItem.tsx similarity index 96% rename from webapp/src/views/projects/translations/history/HistoryItem.tsx rename to webapp/src/views/projects/translations/ToolsPanel/panels/History/HistoryItem.tsx index 6cc6d6a238..fcd98818de 100644 --- a/webapp/src/views/projects/translations/history/HistoryItem.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/History/HistoryItem.tsx @@ -7,14 +7,14 @@ import { components } from 'tg.service/apiSchema.generated'; import { getTextDiff } from 'tg.component/activity/types/getTextDiff'; import { getStateChange } from 'tg.component/activity/types/getStateChange'; import { getAutoChange } from 'tg.component/activity/types/getAutoChange'; -import { DiffInput } from './types'; +import { DiffInput } from './HistoryTypes'; import { ActivityDetailDialog } from 'tg.component/activity/ActivityDetail/ActivityDetailDialog'; import { mapHistoryToActivity } from './mapHistoryToActivity'; -import { SmallActionButton } from '../cell/SmallActionButton'; -import { LimitedHeightText } from '../LimitedHeightText'; +import { SmallActionButton } from '../../common/SmallActionButton'; import { getNoDiffChange } from 'tg.component/activity/types/getNoDiffChange'; import { UserName } from 'tg.component/common/UserName'; import { useCurrentLanguage } from 'tg.hooks/useCurrentLanguage'; +import { LimitedHeightText } from 'tg.component/LimitedHeightText'; type TranslationHistoryModel = components['schemas']['TranslationHistoryModel']; diff --git a/webapp/src/views/projects/translations/history/types.ts b/webapp/src/views/projects/translations/ToolsPanel/panels/History/HistoryTypes.ts similarity index 100% rename from webapp/src/views/projects/translations/history/types.ts rename to webapp/src/views/projects/translations/ToolsPanel/panels/History/HistoryTypes.ts diff --git a/webapp/src/views/projects/translations/history/mapHistoryToActivity.ts b/webapp/src/views/projects/translations/ToolsPanel/panels/History/mapHistoryToActivity.ts similarity index 92% rename from webapp/src/views/projects/translations/history/mapHistoryToActivity.ts rename to webapp/src/views/projects/translations/ToolsPanel/panels/History/mapHistoryToActivity.ts index 074e8c1118..bd4f55bc00 100644 --- a/webapp/src/views/projects/translations/history/mapHistoryToActivity.ts +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/History/mapHistoryToActivity.ts @@ -1,5 +1,5 @@ import { ActivityModel, ActivityTypeEnum } from 'tg.component/activity/types'; -import { TranslationHistoryModel } from './types'; +import { TranslationHistoryModel } from './HistoryTypes'; const TYPE_MAP: Record< TranslationHistoryModel['revisionType'], diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/History/useHistory.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/History/useHistory.tsx new file mode 100644 index 0000000000..4f36b602d1 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/History/useHistory.tsx @@ -0,0 +1,65 @@ +import { useProject } from 'tg.hooks/useProject'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; + +type TranslationHistoryModel = components['schemas']['TranslationHistoryModel']; + +type TranslationViewModel = components['schemas']['TranslationViewModel']; +type LanguageModel = components['schemas']['LanguageModel']; + +type Props = { + keyId: number; + translation: TranslationViewModel | undefined; + language: LanguageModel; +}; + +export const useHistory = ({ keyId, translation, language }: Props) => { + const project = useProject(); + const path = { + projectId: project.id, + translationId: translation?.id as number, + }; + const query = { + size: 20, + }; + + const history = useApiInfiniteQuery({ + url: '/v2/projects/{projectId}/translations/{translationId}/history', + method: 'get', + path, + query, + options: { + enabled: Boolean(translation?.id), + getNextPageParam: (lastPage) => { + if ( + lastPage.page && + lastPage.page.number! < lastPage.page.totalPages! - 1 + ) { + return { + path, + query: { + ...query, + page: lastPage.page!.number! + 1, + }, + }; + } else { + return null; + } + }, + }, + }); + + const fetchMore = () => { + history.fetchNextPage(); + }; + + const historyItems: TranslationHistoryModel[] = []; + + history.data?.pages.forEach((page) => + page._embedded?.revisions?.forEach((item) => historyItems.push(item)) + ); + + historyItems.reverse(); + + return { fetchMore, historyItems, hasNextPage: history.hasNextPage }; +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/KeyboardShortcuts.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/KeyboardShortcuts.tsx new file mode 100644 index 0000000000..565fda0cfc --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/KeyboardShortcuts.tsx @@ -0,0 +1,81 @@ +import { Box, Typography, styled } from '@mui/material'; +import { T } from '@tolgee/react'; +import { IS_MAC, getMetaName } from 'tg.fixtures/isMac'; +import { formatShortcut } from 'tg.fixtures/shortcuts'; +import { Shortcut } from './Shortcut'; + +const StyledContainer = styled(Box)` + height: 100%; + padding: 16px 12px 8px 12px; + margin: 0px 8px; + display: flex; + flex-direction: column; + position: relative; + background: ${({ theme }) => theme.palette.cell.hover}; + border-radius: 16px; +`; + +const StyledItems = styled(Box)` + display: grid; + gap: 8px; + padding-top: 8px; + padding-bottom: 24px; +`; + +export const KeyboardShortcuts = () => { + const listShorttcuts = [ + { + name: , + formula: ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].map((i) => + formatShortcut(i) + ), + }, + { + name: , + formula: formatShortcut('Enter'), + }, + ]; + + const editorShortcuts = [ + { + name: , + formula: formatShortcut('Enter'), + }, + { + name: , + formula: formatShortcut(`${getMetaName()} + Enter`), + }, + { + name: , + formula: formatShortcut(`${getMetaName()} + E`), + }, + { + name: , + formula: IS_MAC + ? formatShortcut(`${getMetaName()} + Shift + S`) + : formatShortcut(`${getMetaName()} + Insert`), + }, + ]; + + return ( + + + + + + {listShorttcuts.map((item, i) => { + return ; + })} + + + + + + + {editorShortcuts.map((item, i) => { + return ; + })} + + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/Shortcut.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/Shortcut.tsx new file mode 100644 index 0000000000..c22a2d85b3 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/KeyboardShortcuts/Shortcut.tsx @@ -0,0 +1,29 @@ +import { Box, Typography, styled } from '@mui/material'; + +const StyledItem = styled(Box)` + display: flex; + justify-content: space-between; +`; + +const StyledItemContent = styled(Typography)``; + +type Props = { + name: React.ReactNode; + formula: React.ReactNode; +}; + +export const Shortcut = ({ name, formula }: Props) => { + return ( + + + {name} + + + {formula} + + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslation.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslation.tsx new file mode 100644 index 0000000000..7a6a23008d --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslation.tsx @@ -0,0 +1,125 @@ +import { Button, styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { GoToBilling } from 'tg.component/GoToBilling'; +import { stringHash } from 'tg.fixtures/stringHash'; +import { useMTStreamed } from './useMTStreamed'; +import { TabMessage } from '../../common/TabMessage'; +import { PanelContentProps } from '../../common/types'; +import { useEffect } from 'react'; +import { MachineTranslationItem } from './MachineTranslationItem'; + +const StyledContainer = styled('div')` + display: flex; + flex-direction: column; +`; + +const StyledValue = styled('div')` + font-size: 15px; + align-self: center; +`; + +const StyledError = styled(StyledValue)` + color: ${({ theme }) => theme.palette.error.main}; +`; + +const OutOfCreditsWrapper = styled('div')` + padding: 8px 12px 8px 12px; + margin: 8px 8px 0px 8px; + display: grid; + background: ${({ theme }) => theme.palette.cell.selected}; + border-radius: 8px; +`; + +export const MachineTranslation: React.FC = ({ + keyData, + language, + project, + setValue, + setItemsCount, + activeVariant, +}) => { + const { t } = useTranslate(); + + const deps = { + keyId: keyData.keyId, + targetLanguageId: language.id, + isPlural: keyData.keyIsPlural, + }; + + const dependenciesHash = stringHash(JSON.stringify(deps)); + + const machineLoadable = useMTStreamed({ + path: { projectId: project.id }, + // @ts-ignore add all dependencies to properly update query + query: { hash: dependenciesHash }, + content: { + 'application/json': { + ...deps, + }, + }, + fetchOptions: { + // error is displayed inside the popup + disableAutoErrorHandle: false, + }, + options: { + keepPreviousData: true, + }, + }); + + const data = machineLoadable.data; + + const baseIsEmpty = data?.baseBlank; + const nothingFetched = !data?.servicesTypes; + const results = data?.servicesTypes.map( + (provider) => [provider, data.result[provider]] as const + ); + const arrayResults = Object.values(data?.result || {}); + const outOfCredit = + arrayResults.every((i) => i?.errorMessage === 'OUT_OF_CREDITS') && + Boolean(arrayResults.length); + const contextPresent = keyData.contextPresent; + + useEffect(() => { + setItemsCount(arrayResults.length); + }, [arrayResults.length]); + + return ( + + {outOfCredit ? ( + + + + ( + + )} + /> + + + ) : baseIsEmpty ? ( + {t('translation_tools_base_empty')} + ) : ( + !nothingFetched && + results?.map(([provider, data]) => { + return ( + + ); + }) + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslationItem.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslationItem.tsx new file mode 100644 index 0000000000..faf808256c --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/MachineTranslationItem.tsx @@ -0,0 +1,131 @@ +import clsx from 'clsx'; +import { Skeleton, styled } from '@mui/material'; + +import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; + +import { ProviderLogo } from './ProviderLogo'; +import { TranslationWithPlaceholders } from '../../../translationVisual/TranslationWithPlaceholders'; +import { CombinedMTResponse } from './useMTStreamed'; +import { + useExtractedPlural, + useVariantExample, +} from '../../common/useExtractedPlural'; +import { T } from '@tolgee/react'; + +const StyledItem = styled('div')` + padding: ${({ theme }) => theme.spacing(0.5, 0.75)}; + margin: ${({ theme }) => theme.spacing(0.5, 0.5)}; + border-radius: 4px; + display: grid; + gap: ${({ theme }) => theme.spacing(0, 1)}; + grid-template-columns: 20px 1fr; + transition: all 0.1s ease-in-out; + transition-property: background color; + + &:hover { + background: ${({ theme }) => theme.palette.emphasis[50]}; + } + &.clickable { + cursor: pointer; + &:hover { + color: ${({ theme }) => theme.palette.primary.main}; + } + } +`; + +const StyledValue = styled('div')` + font-size: 15px; + align-self: center; +`; + +const StyledError = styled(StyledValue)` + color: ${({ theme }) => theme.palette.error.main}; +`; + +const StyledDescription = styled('div')` + font-size: 13px; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledEmpty = styled(StyledValue)` + font-style: italic; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +type Props = { + data: CombinedMTResponse['result'][number]; + provider: string; + isFetching: boolean; + contextPresent: boolean; + setValue: (val: string) => void; + languageTag: string; + pluralVariant: string | undefined; +}; + +export const MachineTranslationItem = ({ + data, + provider, + isFetching, + contextPresent, + languageTag, + setValue, + pluralVariant, +}: Props) => { + const error = data?.errorMessage?.toLowerCase(); + const result = data?.result; + + const text = useExtractedPlural(pluralVariant, data?.result?.output); + const variantExample = useVariantExample(pluralVariant, languageTag); + + const clickable = Boolean(text); + + return ( + { + if (clickable) { + e.preventDefault(); + } + }} + onClick={() => { + if (clickable) { + setValue(text); + } + }} + data-cy="translation-tools-machine-translation-item" + className={clsx({ clickable })} + > + + {result?.output ? ( + <> + +
+ {text === '' ? ( + + + + ) : ( + + )} +
+ {result?.contextDescription && ( + {result.contextDescription} + )} +
+ + ) : error ? ( + + + + ) : !data && isFetching ? ( + + ) : null} +
+ ); +}; diff --git a/webapp/src/views/projects/translations/TranslationTools/ProviderLogo.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/ProviderLogo.tsx similarity index 100% rename from webapp/src/views/projects/translations/TranslationTools/ProviderLogo.tsx rename to webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/ProviderLogo.tsx diff --git a/webapp/src/views/projects/translations/TranslationTools/useMTStreamed.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/useMTStreamed.tsx similarity index 100% rename from webapp/src/views/projects/translations/TranslationTools/useMTStreamed.tsx rename to webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/useMTStreamed.tsx diff --git a/webapp/src/views/projects/translations/TranslationTools/useServiceImg.ts b/webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg.ts similarity index 100% rename from webapp/src/views/projects/translations/TranslationTools/useServiceImg.ts rename to webapp/src/views/projects/translations/ToolsPanel/panels/MachineTranslation/useServiceImg.ts diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemory.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemory.tsx new file mode 100644 index 0000000000..83ad22e64a --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemory.tsx @@ -0,0 +1,77 @@ +import { useTranslate } from '@tolgee/react'; +import { styled } from '@mui/material'; + +import { TabMessage } from '../../common/TabMessage'; +import { PanelContentProps } from '../../common/types'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { stringHash } from 'tg.fixtures/stringHash'; +import { useEffect } from 'react'; +import { TranslationMemoryItem } from './TranslationMemoryItem'; + +const StyledContainer = styled('div')` + display: flex; + flex-direction: column; +`; + +export const TranslationMemory: React.FC = ({ + keyData, + language, + baseLanguage, + project, + setValue, + setItemsCount, + activeVariant, +}) => { + const { t } = useTranslate(); + + const deps = { + keyId: keyData.keyId, + targetLanguageId: language.id, + isPlural: keyData.keyIsPlural, + }; + + const dependenciesHash = stringHash(JSON.stringify(deps)); + + const memory = useApiQuery({ + url: '/v2/projects/{projectId}/suggest/translation-memory', + method: 'post', + // @ts-ignore add all dependencies to properly update query + query: { hash: dependenciesHash, size: 2 }, + path: { projectId: project.id }, + content: { + 'application/json': deps, + }, + }); + + const data = memory.data; + const items = data?._embedded?.translationMemoryItems; + + useEffect(() => { + setItemsCount(items?.length ?? 0); + }, [items?.length]); + + if (!data) { + return null; + } + + return ( + + {items?.length ? ( + items.map((item, i) => ( + + )) + ) : ( + + {t('translation_tools_nothing_found', 'Nothing found')} + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx new file mode 100644 index 0000000000..0ff87f3bd2 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx @@ -0,0 +1,178 @@ +import { styled } from '@mui/material'; +import { green, grey, orange } from '@mui/material/colors'; +import { components } from 'tg.service/apiSchema.generated'; +import { TranslationWithPlaceholders } from 'tg.views/projects/translations/translationVisual/TranslationWithPlaceholders'; +import { + useBaseVariant, + useExtractedPlural, + useVariantExample, +} from '../../common/useExtractedPlural'; +import { T } from '@tolgee/react'; +import clsx from 'clsx'; + +type TranslationMemoryItemModel = + components['schemas']['TranslationMemoryItemModel']; + +const StyledItem = styled('div')` + display: grid; + padding: ${({ theme }) => theme.spacing(0.5, 0.75)}; + margin: ${({ theme }) => theme.spacing(0.5, 0.5)}; + border-radius: 4px; + gap: 0px 10px; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto 3px auto; + grid-template-areas: + 'target target' + 'base base' + 'space space' + 'similarity source'; + font-size: 14px; + color: ${({ theme }) => theme.palette.text.primary}; + transition: all 0.1s ease-in-out; + transition-property: background color; + &:hover { + background: ${({ theme }) => theme.palette.emphasis[50]}; + color: ${({ theme }) => theme.palette.primary.main}; + } + &.clickable { + cursor: pointer; + } +`; + +const StyledTarget = styled('div')` + grid-area: target; + font-size: 15px; +`; + +const StyledBase = styled('div')` + grid-area: base; + font-style: italic; + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 13px; + + & .placeholder-widget { + font-size: 11px; + background: ${({ theme }) => + theme.palette.placeholders.inactive.background}; + border-color: ${({ theme }) => theme.palette.placeholders.inactive.border}; + color: ${({ theme }) => theme.palette.placeholders.inactive.text}; + padding-top: 0px; + padding-bottom: 0px; + } +`; + +const StyledSimilarity = styled('div')` + grid-area: similarity; + font-size: 13px; + color: white; + padding: 1px 9px; + border-radius: 10px; +`; + +const StyledSource = styled('div')` + grid-area: source; + font-size: 13px; + align-self: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledBaseEmpty = styled(StyledBase)` + font-style: italic; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const StyledTargetEmpty = styled(StyledTarget)` + font-style: italic; + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +type Props = { + item: TranslationMemoryItemModel; + setValue(val: string): void; + languageTag: string; + baseLanguageTag: string; + pluralVariant: string | undefined; +}; + +export const TranslationMemoryItem = ({ + item, + languageTag, + baseLanguageTag, + setValue, + pluralVariant, +}: Props) => { + const similarityColor = + item.similarity === 1 + ? green[600] + : item.similarity > 0.7 + ? orange[800] + : grey[600]; + + const targetText = useExtractedPlural(pluralVariant, item.targetText); + + const variantExample = useVariantExample(pluralVariant, languageTag); + + const baseVariant = useBaseVariant( + pluralVariant, + languageTag, + baseLanguageTag + ); + + const baseText = useExtractedPlural(baseVariant, item.baseText); + + const baseVariantExample = useVariantExample(baseVariant, baseLanguageTag); + + return ( + { + e.preventDefault(); + }} + onClick={() => { + if (targetText) { + setValue(targetText); + } + }} + className={clsx({ + clickable: Boolean(targetText), + })} + role="button" + data-cy="translation-tools-translation-memory-item" + > + + {targetText === '' ? ( + + + + ) : ( + + )} + + + {baseText === '' ? ( + + + + ) : ( + + )} + + + {Math.round(100 * item.similarity)}% + + {item.keyName} + + ); +}; diff --git a/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx new file mode 100644 index 0000000000..9ced2affc8 --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/panelsList.tsx @@ -0,0 +1,51 @@ +import { + MachineTranslationIcon, + TranslationMemoryIcon, +} from 'tg.component/CustomIcons'; +import { MachineTranslation } from './panels/MachineTranslation/MachineTranslation'; +import { T } from '@tolgee/react'; +import { TranslationMemory } from './panels/TranslationMemory/TranslationMemory'; +import { Comments, CommentsItemsCount } from './panels/Comments/Comments'; +import { History } from './panels/History/History'; +import { Message, History as HistoryIcon, Keyboard } from '@mui/icons-material'; +import { PanelConfig } from './common/types'; +import { KeyboardShortcuts } from './panels/KeyboardShortcuts/KeyboardShortcuts'; + +export const PANELS_WHEN_INACTIVE = [ + { + id: 'keyboard_shortcuts', + icon: , + name: , + component: KeyboardShortcuts, + }, +]; + +export const PANELS = [ + { + id: 'machine_translation', + icon: , + name: , + component: MachineTranslation, + displayPanel: ({ language, editEnabled }) => !language.base && editEnabled, + }, + { + id: 'translation_memory', + icon: , + name: , + component: TranslationMemory, + displayPanel: ({ language, editEnabled }) => !language.base && editEnabled, + }, + { + id: 'comments', + icon: , + name: , + component: Comments, + itemsCountComponent: CommentsItemsCount, + }, + { + id: 'history', + icon: , + name: , + component: History, + }, +] satisfies PanelConfig[]; diff --git a/webapp/src/views/projects/translations/ToolsPanel/useOpenPanels.ts b/webapp/src/views/projects/translations/ToolsPanel/useOpenPanels.ts new file mode 100644 index 0000000000..df9a8bb36e --- /dev/null +++ b/webapp/src/views/projects/translations/ToolsPanel/useOpenPanels.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { useTranslationsSelector } from '../context/TranslationsContext'; + +const OPEN_PANELS_KEY = '__tolgee_openPanels'; + +export const useOpenPanels = () => { + const mode = useTranslationsSelector((c) => c?.cursor?.mode); + const [openPanels, _setOpenPanels] = useState(() => { + let result; + try { + result = JSON.parse(localStorage.getItem(OPEN_PANELS_KEY) ?? ''); + } catch (e) { + // pass + } + if (!Array.isArray(result)) { + result = ['machine_translation', 'translation_memory']; + } + + if (mode === 'comments' && !result.includes('comments')) { + result.push('comments'); + } + return result; + }); + + function setOpenPanels(value: string[]) { + _setOpenPanels(value); + localStorage.setItem(OPEN_PANELS_KEY, JSON.stringify(value)); + } + + return [openPanels, setOpenPanels] as const; +}; diff --git a/webapp/src/views/projects/translations/TranslationEditor.tsx b/webapp/src/views/projects/translations/TranslationEditor.tsx new file mode 100644 index 0000000000..5356e829df --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationEditor.tsx @@ -0,0 +1,48 @@ +import { EditorView } from 'codemirror'; +import { PluralEditor } from './translationVisual/PluralEditor'; +import { useTranslationCell } from './useTranslationCell'; + +type Props = { + mode: 'placeholders' | 'syntax'; + tools: ReturnType; + editorRef: React.RefObject; +}; + +export const TranslationEditor = ({ mode, tools, editorRef }: Props) => { + const { + editVal, + language, + setState, + setVariant, + setEditValue, + handleSave, + handleClose, + handleInsertBase, + } = tools; + + return ( + (handleClose(true), true) }, + { key: `Mod-e`, run: () => (setState(), true) }, + { key: 'Mod-Enter', run: () => (handleSave('EDIT_NEXT'), true) }, + { key: 'Enter', run: () => (handleSave(), true) }, + { + key: 'Mod-Insert', + mac: 'Cmd-Shift-s', + run: () => (!language.base && handleInsertBase(), true), + }, + ], + }} + /> + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationHeader/KeyCreateDialog.tsx b/webapp/src/views/projects/translations/TranslationHeader/KeyCreateDialog.tsx index 929405a72b..2f78360ff3 100644 --- a/webapp/src/views/projects/translations/TranslationHeader/KeyCreateDialog.tsx +++ b/webapp/src/views/projects/translations/TranslationHeader/KeyCreateDialog.tsx @@ -2,24 +2,14 @@ import { DialogTitle, styled } from '@mui/material'; import { T } from '@tolgee/react'; import { components } from 'tg.service/apiSchema.generated'; -import { LanguagesSelect } from 'tg.component/common/form/LanguagesSelect/LanguagesSelect'; -import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { - useTranslationsSelector, useTranslationsActions, + useTranslationsSelector, } from '../context/TranslationsContext'; import { KeyCreateForm } from '../KeyCreateForm/KeyCreateForm'; -import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; type KeyWithDataModel = components['schemas']['KeyWithDataModel']; -const StyledTitle = styled('div')` - justify-self: stretch; - display: flex; - align-items: flex-start; - justify-content: space-between; -`; - const StyledContent = styled('div')` display: grid; row-gap: ${({ theme }) => theme.spacing(2)}; @@ -38,38 +28,7 @@ export const KeyCreateDialog: React.FC = ({ onDirtyChange, }) => { const { insertTranslation } = useTranslationsActions(); - - const { satisfiesLanguageAccess } = useProjectPermissions(); - const languages = useTranslationsSelector((c) => c.languages)?.filter((l) => - satisfiesLanguageAccess('translations.edit', l.id) - ); - const selectedLanguagesDefault = useTranslationsSelector( - (c) => c.selectedLanguages - ); - - const handleLanguageChange = (langs: string[]) => { - setUrlSelectedLanguages(langs); - }; - - const [urlSelectedLanguages, setUrlSelectedLanguages] = useUrlSearchState( - 'languages', - { - array: true, - cleanup: true, - } - ); - - const selectedLanguages = - (urlSelectedLanguages?.length && (urlSelectedLanguages as string[])) || - selectedLanguagesDefault || - []; - - const selectedLanguagesMapped = selectedLanguages! - .map((l) => { - const language = languages?.find(({ tag }) => tag === l); - return language!; - }) - .filter(Boolean); + const baseLanguage = useTranslationsSelector((c) => c.baseLanguage); const handleOnSuccess = (data: KeyWithDataModel) => { onClose(); @@ -95,25 +54,19 @@ export const KeyCreateDialog: React.FC = ({ screenshotCount: 0, translations, contextPresent: false, + keyIsPlural: data.isPlural, + keyPluralArgName: data.pluralArgName, }); }; return ( <> - - - l.tag)} - onChange={handleLanguageChange} - context="translations-dialog" - /> - + theme.spacing(0, 2, 2, 2)}; overflow: hidden; - width: 100%; display: flex; justify-content: flex-start; `; @@ -56,14 +54,13 @@ type Props = { }; export const StickyHeader: React.FC = ({ height, children }) => { - const { setTopBarHeight } = useHeaderNsActions(); + const { setFloatingBannerHeight } = useHeaderNsActions(); const topNamespace = useHeaderNsContext((c) => c.topNamespace); - const columnSizes = useColumnsContext((c) => c.columnSizes); const topBannerHeight = useGlobalContext((c) => c.topBannerHeight); const topBarHidden = useGlobalContext((c) => !c.topBarHeight); useEffect(() => { - setTopBarHeight(height + (topBarHidden ? 0 : 50)); + setFloatingBannerHeight(height); }, [topBarHidden]); return ( @@ -81,9 +78,9 @@ export const StickyHeader: React.FC = ({ height, children }) => { {topNamespace !== undefined && ( )} diff --git a/webapp/src/views/projects/translations/TranslationOpened.tsx b/webapp/src/views/projects/translations/TranslationOpened.tsx deleted file mode 100644 index 1fa23d9af9..0000000000 --- a/webapp/src/views/projects/translations/TranslationOpened.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { IconButton, styled, Tab, Tabs, useTheme } from '@mui/material'; -import { Close } from '@mui/icons-material'; -import { T } from '@tolgee/react'; - -import { ControlsEditor } from './cell/ControlsEditor'; -import { Editor } from 'tg.component/editor/Editor'; -import { components } from 'tg.service/apiSchema.generated'; -import { - StateInType, - TRANSLATION_STATES, -} from 'tg.constants/translationStates'; -import { Comments } from './comments/Comments'; -import { getMeta, IS_MAC } from 'tg.fixtures/isMac'; -import { - useTranslationsActions, - useTranslationsSelector, -} from './context/TranslationsContext'; -import { ToolsPopup } from './TranslationTools/ToolsPopup'; -import { useTranslationTools } from './TranslationTools/useTranslationTools'; -import { useProject } from 'tg.hooks/useProject'; -import { EditMode } from './context/types'; -import { History } from './history/History'; -import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; - -type LanguageModel = components['schemas']['LanguageModel']; -type TranslationViewModel = components['schemas']['TranslationViewModel']; -type State = components['schemas']['TranslationViewModel']['state']; -type KeyWithTranslationsModel = - components['schemas']['KeyWithTranslationsModel']; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; - align-items: stretch; - min-height: 300px; - background: ${({ theme }) => theme.palette.cell.inside}; -`; - -const StyledEditorContainer = styled('div')` - padding: 12px 12px 0px 12px; - flex-grow: 1; - display: flex; - align-items: stretch; - flex-direction: column; -`; - -const StyledEditorControls = styled('div')` - display: flex; - position: relative; - margin-top: ${({ theme }) => theme.spacing(3)}; -`; - -const StyledTabsWrapper = styled('div')` - display: flex; - border-bottom: 1px solid - ${({ theme }) => - theme.palette.mode === 'dark' - ? theme.palette.emphasis[300] - : theme.palette.divider1}; - justify-content: space-between; - align-items: center; -`; - -const StyledTabs = styled(Tabs)` - max-width: 100%; - overflow: hidden; - min-height: 0px; - margin-bottom: -1px; - - & .scrollButtons { - width: 30px; - } -`; - -const StyledTab = styled(Tab)` - min-height: 0px; - min-width: 60px; - margin: 0px 0px; - padding: 9px 12px; -`; - -const StyledCloseButton = styled(IconButton)` - width: 30px; - height: 30px; - margin-right: 12px; -`; - -type Props = { - value: string; - keyData: KeyWithTranslationsModel; - language: LanguageModel; - translation: TranslationViewModel | undefined; - onChange: (val: string) => void; - onSave: () => void; - onInsertBase: (val: string | undefined) => void; - onCmdSave: () => void; - onCancel: (force: boolean) => void; - onStateChange: (state: StateInType) => void; - state: State; - autofocus: boolean; - className?: string; - mode: EditMode; - onModeChange: (mode: EditMode) => void; - editEnabled: boolean; - stateChangeEnabled: boolean; - cellRef: React.RefObject; - cellPosition?: string; -}; - -export const TranslationOpened: React.FC = ({ - value, - keyData, - language, - translation, - onChange, - onSave, - onInsertBase, - onCmdSave, - onCancel, - onStateChange, - stateChangeEnabled, - state, - autofocus, - className, - mode, - onModeChange, - editEnabled, - cellRef, - cellPosition, -}) => { - const project = useProject(); - const { setTranslationState, updateEdit } = useTranslationsActions(); - const theme = useTheme(); - - const nextState = TRANSLATION_STATES[state]?.next; - - const handleStateChange = () => { - if (nextState) { - setTranslationState({ - state: nextState, - keyId: keyData.keyId, - translationId: translation!.id, - language: language.tag, - }); - } - }; - - const baseLanguage = useTranslationsSelector((v) => - v.languages?.find((l) => l.base) - )?.tag; - const baseText = baseLanguage && keyData.translations[baseLanguage]?.text; - - const data = useTranslationTools({ - projectId: project.id, - keyId: keyData.keyId, - targetLanguageId: language.id, - baseText: baseText, - enabled: !language.base && mode === 'editor', - onValueUpdate: (value) => { - updateEdit({ - value, - }); - }, - }); - - return ( - - - onModeChange(value)} - variant="scrollable" - scrollButtons="auto" - classes={{ scrollButtons: 'scrollButtons' }} - > - {editEnabled && ( - } - value="editor" - data-cy="translations-cell-tab-edit" - /> - )} - - } - value="comments" - data-cy="translations-cell-tab-comments" - /> - } - value="history" - data-cy="translations-cell-tab-history" - /> - - onCancel(true)} - data-cy="translations-cell-close" - > - - - - {mode === 'editor' ? ( - <> - - onCancel(true)} - onSave={onSave} - onInsertBase={() => onInsertBase(baseText)} - direction={getLanguageDirection(language.tag)} - autofocus={autofocus} - shortcuts={{ - [`${getMeta()}-E`]: handleStateChange, - [`${getMeta()}-Enter`]: onCmdSave, - [`${getMeta()}-Insert`]: () => { - !language.base && onInsertBase(baseText); - }, - }} - onKeyDown={(_, e) => { - if (IS_MAC && e.metaKey && e.shiftKey && e.key === 'S') { - !language.base && onInsertBase(baseText); - } - }} - /> - - - onInsertBase(baseText)} - onCancel={() => onCancel(true)} - onStateChange={onStateChange} - /> - - {!language.base && ( - - )} - - ) : mode === 'comments' ? ( - onCancel(true)} - editEnabled={editEnabled} - /> - ) : mode === 'history' ? ( - - ) : null} - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/MachineTranslation.tsx b/webapp/src/views/projects/translations/TranslationTools/MachineTranslation.tsx deleted file mode 100644 index 6b01bb6093..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/MachineTranslation.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Button, Skeleton, styled } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; - -import { TabMessage } from './TabMessage'; -import { useTranslationTools } from './useTranslationTools'; -import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; -import { ProviderLogo } from './ProviderLogo'; -import { CombinedMTResponse } from './useMTStreamed'; -import { UseQueryResult } from 'react-query'; -import { ApiError } from 'tg.service/http/ApiError'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import clsx from 'clsx'; -import { GoToBilling } from 'tg.component/GoToBilling'; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; -`; - -const StyledItem = styled('div')` - padding: ${({ theme }) => theme.spacing(0.5, 0.75)}; - margin: ${({ theme }) => theme.spacing(0.5, 0.5)}; - border-radius: 4px; - display: grid; - gap: ${({ theme }) => theme.spacing(0, 1)}; - grid-template-columns: 20px 1fr; - transition: all 0.1s ease-in-out; - transition-property: background color; - - &:hover { - background: ${({ theme }) => theme.palette.emphasis[100]}; - } - &.clickable { - cursor: pointer; - &:hover { - color: ${({ theme }) => theme.palette.primary.main}; - } - } -`; - -const StyledValue = styled('div')` - font-size: 15px; - align-self: center; -`; - -const StyledError = styled(StyledValue)` - color: ${({ theme }) => theme.palette.error.main}; -`; - -const StyledDescription = styled('div')` - font-size: 13px; - color: ${({ theme }) => theme.palette.text.secondary}; -`; - -type Props = { - machine: UseQueryResult | undefined; - operationsRef: ReturnType['operationsRef']; - languageTag: string; - contextPresent: boolean | undefined; -}; - -export const MachineTranslation: React.FC = ({ - machine, - operationsRef, - languageTag, - contextPresent, -}) => { - const { t } = useTranslate(); - const data = machine?.data; - const baseIsEmpty = data?.baseBlank; - const nothingFetched = !data?.servicesTypes; - const results = data?.servicesTypes.map( - (provider) => [provider, data.result[provider]] as const - ); - const arrayResults = Object.values(data?.result || {}); - const outOfCredit = - arrayResults.every((i) => i?.errorMessage === 'OUT_OF_CREDITS') && - Boolean(arrayResults.length); - - return ( - - {outOfCredit ? ( - - - - ( - - )} - /> - - - ) : baseIsEmpty ? ( - {t('translation_tools_base_empty')} - ) : ( - !nothingFetched && - results?.map(([provider, data]) => { - const error = data?.errorMessage?.toLowerCase(); - const result = data?.result; - const clickable = data?.result?.output; - return ( - { - if (clickable) { - e.preventDefault(); - } - }} - onClick={() => { - if (clickable) { - operationsRef.current.updateTranslation(result!.output); - } - }} - data-cy="translation-tools-machine-translation-item" - className={clsx({ clickable })} - > - - {result?.output ? ( - <> - -
- {result?.output} -
- {result?.contextDescription && ( - - {result.contextDescription} - - )} -
- - ) : error ? ( - - - - ) : !data && machine?.isFetching ? ( - - ) : null} -
- ); - }) - )} -
- ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/PopupArrow.tsx b/webapp/src/views/projects/translations/TranslationTools/PopupArrow.tsx deleted file mode 100644 index 49d9470164..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/PopupArrow.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { styled } from '@mui/material'; - -const SIZE = 12; -const PADDING = 10; - -const StyledWrapper = styled('div')` - top: ${-(SIZE + PADDING)}px; - padding: ${PADDING}px; - padding-bottom: 0px; - position: absolute; - overflow-y: hidden; - pointer-events: none; -`; - -const StyledArrow = styled('div')` - width: 0px; - height: 0px; - border-left: ${SIZE}px solid transparent; - border-right: ${SIZE}px solid transparent; - border-bottom: ${SIZE}px solid ${({ theme }) => theme.palette.cell.selected}; - filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.5)); -`; - -type Props = { - position: string; -}; - -export const PopupArrow: React.FC = ({ position }) => { - return ( - - - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/ToolsBottomPanel.tsx b/webapp/src/views/projects/translations/TranslationTools/ToolsBottomPanel.tsx deleted file mode 100644 index fd6e21a177..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/ToolsBottomPanel.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import TranslationTools, { - Props as TranslationToolsProps, -} from './TranslationTools'; -import { BottomPanel } from 'tg.component/bottomPanel/BottomPanel'; - -const TOOLS_BOTTOM_HEIGHT = 200; - -type Props = { - data: TranslationToolsProps['data']; - languageTag: TranslationToolsProps['languageTag']; -}; - -export const ToolsBottomPanel: React.FC = ({ data, languageTag }) => { - return ( - - {(width) => ( - - )} - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/ToolsPopup.tsx b/webapp/src/views/projects/translations/TranslationTools/ToolsPopup.tsx deleted file mode 100644 index 28929328b2..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/ToolsPopup.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Popper, styled } from '@mui/material'; - -import TranslationTools, { - Props as TranslationToolsProps, -} from './TranslationTools'; -import { PopupArrow } from './PopupArrow'; - -export const TOOLS_HEIGHT = 200; - -const StyledPopper = styled('div')` - position: relative; - margin-top: 5px; -`; - -const StyledPopperContent = styled('div')` - display: flex; - height: ${TOOLS_HEIGHT}px; - background: ${({ theme }) => theme.palette.cell.inside}; - box-shadow: ${({ theme }) => - theme.palette.mode === 'dark' - ? `${theme.shadows[5]}, ${theme.shadows[3]}` - : theme.shadows[3]}; - border-radius: ${({ theme }) => theme.shape.borderRadius}; -`; - -type Props = { - anchorEl: HTMLDivElement | undefined; - cellPosition?: string; - data: TranslationToolsProps['data']; - languageTag: TranslationToolsProps['languageTag']; -}; - -export const ToolsPopup: React.FC = ({ - anchorEl, - cellPosition, - data, - languageTag, -}) => { - const [width, setWidth] = useState(); - - useEffect(() => { - setWidth(anchorEl?.offsetWidth); - }); - - return width !== undefined ? ( - - - - - - - - - ) : null; -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/ToolsTab.tsx b/webapp/src/views/projects/translations/TranslationTools/ToolsTab.tsx deleted file mode 100644 index 609ed07ba2..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/ToolsTab.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { styled, Typography } from '@mui/material'; -import { TabMessage } from './TabMessage'; -import { NoCreditsHint } from 'tg.component/NoCreditsHint'; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; - min-width: 0px; -`; - -const StyledTab = styled('div')` - display: flex; - align-items: center; - gap: ${({ theme }) => theme.spacing(1)}; - padding: ${({ theme }) => theme.spacing(0.5, 1)}; - background: ${({ theme }) => theme.palette.cell.selected}; - border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; - text-transform: uppercase; - color: ${({ theme }) => theme.palette.text.secondary}; - position: sticky; - top: 0px; - height: 32px; - flex-shrink: 1; - flex-basis: 0px; -`; - -const StyledBadge = styled('div')` - background: ${({ theme }) => theme.palette.cell.inside}; - padding: 2px 4px; - border-radius: 12px; - font-size: 12px; - height: 20px; - min-width: 20px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -`; - -const StyledTitle = styled(Typography)` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 14px; -`; - -type Props = { - icon: React.ReactNode; - title: string; - badgeNumber?: number; - error?: any; -}; - -export const ToolsTab: React.FC = ({ - icon, - title, - badgeNumber, - children, - error, -}) => { - const errorCode = error?.message || error?.code || error; - - return ( - - - {icon} - {title} - {badgeNumber ? {badgeNumber} : null} - - - {error ? ( - - - - ) : ( - children - )} - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/TranslationMemory.tsx b/webapp/src/views/projects/translations/TranslationTools/TranslationMemory.tsx deleted file mode 100644 index 0175240bb6..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/TranslationMemory.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useTranslate } from '@tolgee/react'; -import { styled } from '@mui/material'; - -import { components } from 'tg.service/apiSchema.generated'; -import { green, grey, orange } from '@mui/material/colors'; -import { TabMessage } from './TabMessage'; -import { useTranslationTools } from './useTranslationTools'; - -type PagedModelTranslationMemoryItemModel = - components['schemas']['PagedModelTranslationMemoryItemModel']; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; -`; - -const StyledItem = styled('div')` - display: grid; - padding: ${({ theme }) => theme.spacing(0.5, 0.75)}; - margin: ${({ theme }) => theme.spacing(0.5, 0.5)}; - border-radius: 4px; - gap: 0px 10px; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto 3px auto; - grid-template-areas: - 'target target' - 'base base' - 'space space' - 'similarity source'; - font-size: 14px; - cursor: pointer; - color: ${({ theme }) => theme.palette.text.primary}; - transition: all 0.1s ease-in-out; - transition-property: background color; - &:hover { - background: ${({ theme }) => theme.palette.emphasis[100]}; - color: ${({ theme }) => theme.palette.primary.main}; - } -`; - -const StyledTarget = styled('div')` - grid-area: target; - font-size: 15px; -`; - -const StyledBase = styled('div')` - grid-area: base; - font-style: italic; - color: ${({ theme }) => theme.palette.text.secondary}; - font-size: 13px; -`; - -const StyledSimilarity = styled('div')` - grid-area: similarity; - font-size: 13px; - color: white; - padding: 1px 9px; - border-radius: 10px; -`; - -const StyledSource = styled('div')` - grid-area: source; - font-size: 13px; - align-self: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: ${({ theme }) => theme.palette.text.secondary}; -`; - -type Props = { - data: PagedModelTranslationMemoryItemModel | undefined; - operationsRef: ReturnType['operationsRef']; -}; - -export const TranslationMemory: React.FC = ({ data, operationsRef }) => { - const { t } = useTranslate(); - const items = data?._embedded?.translationMemoryItems; - - if (!data) { - return null; - } - - return ( - - {items?.length ? ( - items.map((item) => { - const similarityColor = - item.similarity === 1 - ? green[600] - : item.similarity > 0.7 - ? orange[800] - : grey[600]; - return ( - { - e.preventDefault(); - }} - onClick={() => { - operationsRef.current.updateTranslation(item.targetText); - }} - role="button" - data-cy="translation-tools-translation-memory-item" - > - {item.targetText} - {item.baseText} - - {Math.round(100 * item.similarity)}% - - {item.keyName} - - ); - }) - ) : ( - - {t('translation_tools_nothing_found', 'Nothing found')} - - )} - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationTools/TranslationTools.tsx b/webapp/src/views/projects/translations/TranslationTools/TranslationTools.tsx deleted file mode 100644 index a611504e16..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/TranslationTools.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import { styled } from '@mui/material'; -import { useTranslate } from '@tolgee/react'; - -import { - MachineTranslationIcon, - TranslationMemoryIcon, -} from 'tg.component/CustomIcons'; -import { ToolsTab } from './ToolsTab'; -import { useTranslationTools } from './useTranslationTools'; -import { TranslationMemory } from './TranslationMemory'; -import { MachineTranslation } from './MachineTranslation'; -import { SmoothProgress } from 'tg.component/SmoothProgress'; -import { useConfig } from 'tg.globalContext/helpers'; - -const HORIZONTAL_BRAKEPOINT = 500; - -const StyledContainer = styled('div')` - overflow: auto; -`; - -const StyledGrid = styled('div')` - display: grid; - grid-auto-flow: dense; -`; - -const StyledLoadingWrapper = styled('div')` - position: absolute; - top: 0px; - left: 0px; - right: 0px; - border-radius: 4px 4px 0px 0px; - overflow: hidden; -`; - -const StyledSmoothProgress = styled(SmoothProgress)` - background: ${({ theme }) => theme.palette.emphasis[400]}; -`; - -export type Props = { - width: number; - data: ReturnType; - languageTag: string; -}; - -const TranslationTools = React.memo(function TranslationTools({ - width, - data, - languageTag, -}: Props) { - const { t } = useTranslate(); - const config = useConfig(); - - const isVertical = width === undefined || width < HORIZONTAL_BRAKEPOINT; - - const mtEnabled = Object.values( - config?.machineTranslationServices.services - ).some(({ enabled }) => enabled); - - const numberOfItems = mtEnabled ? 2 : 1; - - const gridTemplateColumns = isVertical ? '1fr' : '1fr '.repeat(numberOfItems); - - return ( - - - } - badgeNumber={ - data.memory?.data?._embedded?.translationMemoryItems?.length - } - error={data.memory?.error} - > - - - - {mtEnabled && ( - } - badgeNumber={Object.keys(data.machine?.data?.result || {}).length} - error={data.machine?.error} - > - - - )} - - - - - - ); -}); - -export default TranslationTools; diff --git a/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts b/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts deleted file mode 100644 index afd6df2301..0000000000 --- a/webapp/src/views/projects/translations/TranslationTools/useTranslationTools.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useMemo, useRef } from 'react'; - -import { stringHash } from 'tg.fixtures/stringHash'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { useTranslationsSelector } from '../context/TranslationsContext'; -import { useMTStreamed } from './useMTStreamed'; - -type Props = { - projectId: number; - keyId: number; - targetLanguageId: number; - baseText: string | undefined; - enabled?: boolean; - onValueUpdate: (value: string) => void; -}; - -export const useTranslationTools = ({ - projectId, - keyId, - baseText, - targetLanguageId, - onValueUpdate, - enabled = true, -}: Props) => { - const contextPresent = useTranslationsSelector( - (c) => c.translations?.find((item) => item.keyId === keyId)?.contextPresent - ); - - const dependencies = { - keyId, - targetLanguageId, - baseText, - }; - - const dependenciesHash = stringHash(JSON.stringify(dependencies)); - - const data = { - keyId, - targetLanguageId, - // if there is not keyId, send base text, to be used for search - baseText: keyId === undefined ? baseText : undefined, - }; - - const memory = useApiQuery({ - url: '/v2/projects/{projectId}/suggest/translation-memory', - method: 'post', - // @ts-ignore add all dependencies to properly update query - query: { hash: dependenciesHash }, - path: { projectId }, - content: { - 'application/json': data, - }, - options: { - enabled, - }, - }); - - const machine = useMTStreamed({ - path: { projectId }, - content: { 'application/json': { ...data } }, - // @ts-ignore add all dependencies to properly update query - query: { hash: dependenciesHash }, - fetchOptions: { - // error is displayed inside the popup - disableAutoErrorHandle: false, - }, - options: { - keepPreviousData: true, - enabled: enabled, - }, - }); - - const updateTranslation = (value: string) => { - onValueUpdate(value); - }; - - const operations = { - updateTranslation, - }; - - const operationsRef = useRef(operations); - - operationsRef.current = operations; - - return useMemo( - () => ({ - operationsRef, - isFetching: memory.isFetching || machine.isFetching, - memory: enabled ? memory : undefined, - machine: enabled ? machine : undefined, - contextPresent, - }), - [ - memory.status, - memory.isFetching, - memory.data, - operationsRef, - machine.status, - machine.isFetching, - machine.data, - machine.dataUpdatedAt, - ] - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationVisual.tsx b/webapp/src/views/projects/translations/TranslationVisual.tsx deleted file mode 100644 index a4fec348b3..0000000000 --- a/webapp/src/views/projects/translations/TranslationVisual.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useMemo } from 'react'; -import { styled, Tooltip, Typography } from '@mui/material'; - -import { icuVariants } from 'tg.component/editor/icuVariants'; -import { LimitedHeightText } from './LimitedHeightText'; -import { DirectionLocaleWrapper } from './DirectionLocaleWrapper'; -import { useTranslate } from '@tolgee/react'; - -const StyledVariants = styled('div')` - display: grid; - grid-template-columns: 80px 1fr; - column-gap: 4px; - - & .textWrapped { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - & .chip { - padding: 0px 5px 0px 5px; - box-sizing: border-box; - background: ${({ theme }) => - theme.palette.mode === 'dark' - ? theme.palette.emphasis[200] - : theme.palette.emphasis[100]}; - border-radius: 4px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 100%; - justify-self: start; - align-self: start; - padding-bottom: 1px; - height: 24px; - margin-bottom: 2px; - } -`; - -const StyledDisabled = styled(DirectionLocaleWrapper)` - color: ${({ theme }) => theme.palette.text.disabled}; - cursor: default; -`; - -const StyledParameter = styled(Typography)` - display: flex; - margin: 1px 0px 3px 0px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -`; - -type Props = { - limitLines?: boolean; - wrapVariants?: boolean; - text: string | undefined; - locale: string; - width?: number | string; - disabled: boolean; -}; - -export const TranslationVisual: React.FC = ({ - text, - limitLines, - locale, - width, - disabled, -}) => { - const { t } = useTranslate(); - const { variants, parameters } = useMemo( - () => icuVariants(text || '', locale), - [text] - ); - - if (disabled) { - return ( - - - {t('translation_visual_disabled')} - - - ); - } else if (!variants) { - return ( - - - {text} - - - ); - } else { - const allParams = parameters - .filter((p) => !['argument', 'tag'].includes(p.function || '')) - .map((p) => p.name) - .join(', '); - return ( -
- {allParams && ( - - {allParams} - - )} - {variants.length === 1 ? ( - - - {variants[0].value} - - - ) : ( - - - {variants.map(({ option, value }, i) => ( - -
{option}
-
- - {value} - -
-
- ))} -
-
- )} -
- ); - } -}; diff --git a/webapp/src/views/projects/translations/Translations.tsx b/webapp/src/views/projects/translations/Translations.tsx index 0ee1fb0eea..df95d4730e 100644 --- a/webapp/src/views/projects/translations/Translations.tsx +++ b/webapp/src/views/projects/translations/Translations.tsx @@ -1,27 +1,40 @@ import { useEffect, useMemo } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Box, Button } from '@mui/material'; +import { Box, Button, styled, useMediaQuery } from '@mui/material'; import { Link } from 'react-router-dom'; import { LINKS, PARAMS } from 'tg.constants/links'; +import { useProject } from 'tg.hooks/useProject'; +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; +import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; + import { useTranslationsActions, useTranslationsSelector, } from './context/TranslationsContext'; -import { useProject } from 'tg.hooks/useProject'; import { TranslationsTable } from './TranslationsTable/TranslationsTable'; import { TranslationsHeader } from './TranslationHeader/TranslationsHeader'; import { TranslationsList } from './TranslationsList/TranslationsList'; import { useTranslationsShortcuts } from './context/shortcuts/useTranslationsShortcuts'; -import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; -import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; -import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { BaseProjectView } from '../BaseProjectView'; import { TranslationsToolbar } from './TranslationsToolbar'; -import { useColumnsContext } from './context/ColumnsContext'; import { BatchOperationsChangeIndicator } from './BatchOperations/BatchOperationsChangeIndicator'; +import { FloatingToolsPanel } from './ToolsPanel/FloatingToolsPanel'; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: 1fr auto; +`; export const Translations = () => { + const { setQuickStartOpen, quickStartForceFloating } = useGlobalActions(); + const quickStartEnabled = useGlobalContext((c) => c.quickStartGuide.enabled); + const isSmall = useMediaQuery(`@media (max-width: ${800}px)`); const { t } = useTranslate(); const project = useProject(); const projectPermissions = useProjectPermissions(); @@ -30,7 +43,7 @@ export const Translations = () => { const isFetching = useTranslationsSelector((c) => c.isFetching); const view = useTranslationsSelector((v) => v.view); const translations = useTranslationsSelector((c) => c.translations); - const totalWidth = useColumnsContext((c) => c.totalWidth); + const sidePanelOpen = useTranslationsSelector((c) => c.sidePanelOpen); const filtersOrSearchApplied = useTranslationsSelector((c) => Boolean(Object.values(c.filters).filter(Boolean).length || c.urlSearch) @@ -67,6 +80,17 @@ export const Translations = () => { setFilters({}); }; + // hide quick start panel + useEffect(() => { + if (sidePanelOpen && quickStartEnabled) { + quickStartForceFloating(true); + setQuickStartOpen(true); + return () => { + quickStartForceFloating(false); + }; + } + }, [sidePanelOpen, quickStartEnabled]); + const renderPlaceholder = () => memoizedFiltersOrSearchApplied ? ( { ); + const toolsPanelOpen = sidePanelOpen && !isSmall; + return ( { }), ], ]} + wrapperProps={{ pb: 0 }} > - {translationsEmpty ? ( - renderPlaceholder() - ) : view === 'TABLE' ? ( - - ) : ( - - )} - + + {translationsEmpty ? ( + renderPlaceholder() + ) : view === 'TABLE' ? ( + + ) : ( + + )} + {toolsPanelOpen && } + + ); }; diff --git a/webapp/src/views/projects/translations/TranslationsList/CellTranslation.tsx b/webapp/src/views/projects/translations/TranslationsList/CellTranslation.tsx index 36d469ce47..92684a9087 100644 --- a/webapp/src/views/projects/translations/TranslationsList/CellTranslation.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/CellTranslation.tsx @@ -1,90 +1,28 @@ import { useRef } from 'react'; import clsx from 'clsx'; -import { Box, styled } from '@mui/material'; - import { components } from 'tg.service/apiSchema.generated'; -import { CircledLanguageIcon } from 'tg.component/languages/CircledLanguageIcon'; + import { CELL_CLICKABLE, CELL_PLAIN, CELL_RAISED, - CELL_SELECTED, StyledCell, } from '../cell/styles'; -import { useEditableRow } from '../useEditableRow'; -import { useTranslationsActions } from '../context/TranslationsContext'; -import { TranslationVisual } from '../TranslationVisual'; +import { useTranslationCell } from '../useTranslationCell'; import { CellStateBar } from '../cell/CellStateBar'; -import { ControlsTranslation } from '../cell/ControlsTranslation'; -import { TranslationOpened } from '../TranslationOpened'; -import { TranslationFlags } from '../cell/TranslationFlags'; -import { StateInType } from 'tg.constants/translationStates'; +import { TranslationRead } from './TranslationRead'; +import { TranslationWrite } from './TranslationWrite'; type LanguageModel = components['schemas']['LanguageModel']; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; type TranslationViewModel = components['schemas']['TranslationViewModel']; -const StyledWrapper = styled(StyledCell)` - &.splitContainer { - display: flex; - flex-grow: 1; - position: relative; - } -`; - -const StyledContainer = styled('div')` - flex-basis: 50%; - flex-grow: 1; - position: relative; - display: grid; - grid-template-columns: 40px 60px 1fr; - grid-template-rows: 1fr auto; - grid-template-areas: - 'flag language translation ' - 'controls controls controls '; -`; - -const StyledAutoTranslationIndicator = styled(TranslationFlags)` - position: relative; - height: 0px; - justify-self: start; -`; - -const StyledTranslation = styled('div')` - grid-area: translation; - margin: 12px 12px 8px 0px; -`; - -const StyledTranslationContent = styled('div')` - position: relative; -`; - -const StyledLanguage = styled(Box)` - margin: 12px 8px 8px 0px; -`; - -const StyledCircledLanguageIcon = styled(CircledLanguageIcon)` - grid-area: flag; - margin: 12px 8px 8px 12px; - width: 20px; - height: 20px; - padding: 1px; -`; - -const StyledTranslationOpened = styled(TranslationOpened)` - overflow: hidden; - flex-basis: 50%; - flex-grow: 1; -`; - type Props = { data: KeyWithTranslationsModel; language: LanguageModel; colIndex?: number; onResize?: (colIndex: number) => void; - editEnabled: boolean; - stateChangeEnabled: boolean; width?: number | string; active: boolean; lastFocusable: boolean; @@ -96,150 +34,59 @@ export const CellTranslation: React.FC = ({ language, colIndex, onResize, - editEnabled, - stateChangeEnabled, width, active, lastFocusable, className, }) => { const cellRef = useRef(null); - const { setTranslationState } = useTranslationsActions(); const translation = data.translations[language.tag] as | TranslationViewModel | undefined; - const { - isEditing, - editVal, - value, - setValue, - handleOpen, - handleClose, - handleInsertBase, - handleSave, - handleModeChange, - autofocus, - isEditingRow, - } = useEditableRow({ - keyId: data.keyId, - keyName: data.keyName, - defaultVal: translation?.text, - language: language.tag, + const tools = useTranslationCell({ + keyData: data, + language: language, cellRef: cellRef, }); - const handleStateChange = (state: StateInType) => { - setTranslationState({ - keyId: data.keyId, - language: language.tag, - translationId: data.translations[language.tag]?.id, - state, - }); - }; + const { isEditing, editEnabled: canEditTranslation } = tools; const handleResize = () => { onResize?.(colIndex || 0); }; - const toggleEdit = () => { - if (isEditing) { - handleClose(); - } else { - handleOpen('editor'); - } - }; - - const showAllLines = isEditing || (language.base && isEditingRow); - const state = translation?.state || 'UNTRANSLATED'; const disabled = state === 'DISABLED'; - const editable = editEnabled && !disabled; + const editable = canEditTranslation && !disabled; return ( - - toggleEdit() : undefined} - data-cy="translations-table-cell" - > - - - - {language.tag} - - - - - - - - - - - - {!isEditing && ( - handleOpen('editor')} - onComments={() => handleOpen('comments')} - commentsCount={translation?.commentCount} - unresolvedCommentCount={translation?.unresolvedCommentCount} - stateChangeEnabled={stateChangeEnabled} - editEnabled={editable} - state={state} - onStateChange={handleStateChange} - active={active} - lastFocusable={lastFocusable} - /> - )} - - - {editVal && ( - setValue(v as string)} - onSave={() => handleSave()} - onCmdSave={() => handleSave('EDIT_NEXT')} - onInsertBase={handleInsertBase} - onCancel={handleClose} - autofocus={autofocus} - state={state} - onStateChange={handleStateChange} - stateChangeEnabled={stateChangeEnabled} - mode={editVal.mode} - onModeChange={handleModeChange} - editEnabled={editable} - cellRef={cellRef} + + {isEditing ? ( + + ) : ( + )} - + ); }; diff --git a/webapp/src/views/projects/translations/TranslationsList/RowList.tsx b/webapp/src/views/projects/translations/TranslationsList/RowList.tsx index e548840093..5d64b65583 100644 --- a/webapp/src/views/projects/translations/TranslationsList/RowList.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/RowList.tsx @@ -23,10 +23,8 @@ const StyledContainer = styled('div')` `; const StyledLanguages = styled('div')` - display: flex; - flex-direction: column; + display: grid; position: relative; - align-items: stretch; `; type Props = { @@ -46,8 +44,7 @@ export const RowList: React.FC = React.memo(function RowList({ bannerBefore, bannerAfter, }) { - const { satisfiesPermission, satisfiesLanguageAccess } = - useProjectPermissions(); + const { satisfiesPermission } = useProjectPermissions(); const [hover, setHover] = useState(false); const [focus, setFocus] = useState(false); const active = hover || focus; @@ -84,20 +81,10 @@ export const RowList: React.FC = React.memo(function RowList({ data={data} width={columnSizes[0]} active={relaxedActive} - position="left" className={keyClassName} /> {languages.map((language, index) => { - const canChangeState = satisfiesLanguageAccess( - 'translations.state-edit', - language.id - ); - - const canEdit = satisfiesLanguageAccess( - 'translations.edit', - language.id - ); return ( = React.memo(function RowList({ language={language} colIndex={0} onResize={onResize} - editEnabled={canEdit} - stateChangeEnabled={canChangeState} width={columnSizes[1]} active={relaxedActive} className={clsx({ diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationLanguage.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationLanguage.tsx new file mode 100644 index 0000000000..1f198e845d --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationLanguage.tsx @@ -0,0 +1,53 @@ +import { styled, useTheme } from '@mui/material'; +import { FlagImage } from 'tg.component/languages/FlagImage'; +import { components } from 'tg.service/apiSchema.generated'; +import { TranslationFlags } from '../cell/TranslationFlags'; + +type LanguageModel = components['schemas']['LanguageModel']; +type KeyWithTranslationsModel = + components['schemas']['KeyWithTranslationsModel']; + +const StyledLanguage = styled('div')` + display: flex; + grid-area: language; + gap: 8px; + align-items: center; + font-size: 13px; +`; + +const StyledLanguageName = styled('div')` + padding-right: 8px; +`; + +type Props = { + language: LanguageModel; + keyData: KeyWithTranslationsModel; + inactive?: boolean; + className: string; +}; + +export const TranslationLanguage = ({ + language, + keyData, + inactive, + className, +}: Props) => { + const theme = useTheme(); + return ( + + + + {language.name} + + + + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx new file mode 100644 index 0000000000..d0a9189218 --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationRead.tsx @@ -0,0 +1,123 @@ +import clsx from 'clsx'; +import { styled } from '@mui/material'; + +import { useTranslationCell } from '../useTranslationCell'; +import { TranslationVisual } from '../translationVisual/TranslationVisual'; +import { ControlsTranslation } from '../cell/ControlsTranslation'; +import { TranslationLanguage } from './TranslationLanguage'; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: + 'language controls-t ' + 'translation translation ' + 'controls-b controls-b '; + + .language { + align-self: start; + padding: 12px 12px 4px 16px; + } + + .controls-t { + padding-right: 6px; + padding-top: 12px; + grid-area: controls-t; + justify-self: end; + } + + .controls-b { + padding: 0px 12px 4px 12px; + grid-area: controls-b; + } +`; + +const StyledTranslation = styled('div')` + grid-area: translation; + min-height: 23px; + margin: 0px 12px 16px 16px; + position: relative; +`; + +type Props = { + colIndex?: number; + width?: number | string; + active: boolean; + lastFocusable: boolean; + className?: string; + tools: ReturnType; +}; + +export const TranslationRead: React.FC = ({ + width, + active, + lastFocusable, + className, + tools, +}) => { + const { + isEditing, + handleOpen, + handleClose, + setState: handleStateChange, + translation, + language, + canChangeState, + keyData, + editEnabled, + } = tools; + + const toggleEdit = () => { + if (isEditing) { + handleClose(); + } else { + handleOpen(); + } + }; + + const state = translation?.state || 'UNTRANSLATED'; + + const disabled = state === 'DISABLED'; + const editable = editEnabled && !disabled; + + return ( + toggleEdit() : undefined} + > + + + handleOpen()} + onComments={() => handleOpen('comments')} + commentsCount={translation?.commentCount} + unresolvedCommentCount={translation?.unresolvedCommentCount} + stateChangeEnabled={canChangeState} + editEnabled={editable} + state={state} + onStateChange={handleStateChange} + active={active} + lastFocusable={lastFocusable} + className="controls-t" + /> + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx new file mode 100644 index 0000000000..f90970bc9a --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationWrite.tsx @@ -0,0 +1,195 @@ +import { useRef, useState } from 'react'; +import { Box, IconButton, Tooltip, styled } from '@mui/material'; +import { Placeholder } from '@tginternal/editor'; + +import { ControlsEditorMain } from '../cell/ControlsEditorMain'; +import { ControlsEditorSmall } from '../cell/ControlsEditorSmall'; +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { EditorView } from 'codemirror'; +import { useTranslationCell } from '../useTranslationCell'; +import { TranslationLanguage } from './TranslationLanguage'; +import { TranslationEditor } from '../TranslationEditor'; +import { MissingPlaceholders } from '../cell/MissingPlaceholders'; +import { useMissingPlaceholders } from '../cell/useMissingPlaceholders'; +import { TranslationVisual } from '../translationVisual/TranslationVisual'; +import { ControlsEditorReadOnly } from '../cell/ControlsEditorReadOnly'; +import { useBaseTranslation } from '../useBaseTranslation'; +import { Help } from '@mui/icons-material'; +import { useTranslate } from '@tolgee/react'; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: + 'language controls-t ' + 'editor editor ' + 'controls-b controls-b '; + + .language { + align-self: start; + padding: 12px 12px 4px 16px; + } + + .editor { + padding: 0px 12px 12px 16px; + grid-area: editor; + } + + .controls-t { + grid-area: controls-t; + justify-self: end; + padding-right: 10px; + padding-top: 12px; + } +`; + +const StyledBottom = styled(Box)` + grid-area: controls-b; + padding: 0px 12px 4px 16px; + display: flex; + justify-content: space-between; + margin-bottom: 8px; + flex-wrap: wrap; + gap: 8px; + align-items: center; + .controls-main { + flex-grow: 1; + justify-content: end; + } +`; + +type Props = { + tools: ReturnType; +}; + +export const TranslationWrite: React.FC = ({ tools }) => { + const { + value, + keyData, + translation, + language, + canChangeState, + setState, + handleSave, + handleClose, + handleInsertBase, + editEnabled, + disabled, + } = tools; + const { t } = useTranslate(); + const editVal = tools.editVal!; + const state = translation?.state || 'UNTRANSLATED'; + const activeVariant = editVal.activeVariant; + + const [mode, setMode] = useState<'placeholders' | 'syntax'>('placeholders'); + const editorRef = useRef(null); + const baseLanguage = useTranslationsSelector((c) => c.baseLanguage); + const nested = Boolean(editVal.value.parameter); + + const baseTranslation = useBaseTranslation( + activeVariant, + keyData.translations[baseLanguage]?.text, + keyData.keyIsPlural + ); + + const missingPlaceholders = useMissingPlaceholders({ + baseTranslation, + currentTranslation: value, + nested, + }); + + const handleModeToggle = () => { + setMode((mode) => (mode === 'syntax' ? 'placeholders' : 'syntax')); + }; + + const handlePlaceholderClick = (placeholder: Placeholder) => { + if (editorRef.current) { + const state = editorRef.current.state; + const selection = state.selection; + const placeholderText = placeholder.normalizedValue || ''; + const transactions = selection.ranges.map((value) => + state.update({ + changes: { + from: value.from, + to: value.to, + insert: placeholderText, + }, + selection: { + anchor: value.from + placeholderText.length, + }, + }) + ); + editorRef.current.update(transactions); + } + }; + + return ( + + + e.preventDefault(), + className: 'controls-t', + }} + state={state} + mode={mode} + isBaseLanguage={language.base} + stateChangeEnabled={canChangeState} + onInsertBase={editEnabled ? handleInsertBase : undefined} + onStateChange={setState} + onModeToggle={editEnabled ? handleModeToggle : undefined} + /> + e.preventDefault()} className="editor"> + {editEnabled ? ( + + ) : ( + + )} + + + e.preventDefault()}> + {editEnabled ? ( + <> + + + + + + + + + handleClose(true)} + /> + + ) : ( + handleClose(true)} + /> + )} + + + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx b/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx index de2f508fcf..5099260362 100644 --- a/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx +++ b/webapp/src/views/projects/translations/TranslationsList/TranslationsList.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useEffect, useRef } from 'react'; -import ReactList from 'react-list'; import { styled } from '@mui/material'; +import { ReactList } from 'tg.component/reactList/ReactList'; import { components } from 'tg.service/apiSchema.generated'; import { @@ -11,18 +11,16 @@ import { ColumnResizer } from '../ColumnResizer'; import { RowList } from './RowList'; import { NamespaceBanner } from '../Namespace/NamespaceBanner'; import { useNsBanners } from '../context/useNsBanners'; -import { - useColumnsActions, - useColumnsContext, -} from '../context/ColumnsContext'; + import { NAMESPACE_BANNER_SPACING } from '../cell/styles'; +import { useColumns } from '../useColumns'; type LanguageModel = components['schemas']['LanguageModel']; const StyledContainer = styled('div')` display: flex; position: relative; - margin: 10px 0px 100px 0px; + margin: 10px 0px 0px 0px; border-left: 0px; border-right: 0px; background: ${({ theme }) => theme.palette.background.default}; @@ -31,7 +29,11 @@ const StyledContainer = styled('div')` align-items: stretch; `; -export const TranslationsList = () => { +type Props = { + toolsPanelOpen: boolean; +}; + +export const TranslationsList = ({ toolsPanelOpen }: Props) => { const tableRef = useRef(null); const reactListRef = useRef(null); const { fetchMore, registerList, unregisterList } = useTranslationsActions(); @@ -42,17 +44,18 @@ export const TranslationsList = () => { ); const isFetchingMore = useTranslationsSelector((v) => v.isFetchingMore); const hasMoreToFetch = useTranslationsSelector((v) => v.hasMoreToFetch); - const cursorKeyId = useTranslationsSelector((c) => c.cursor?.keyId); - const columnSizes = useColumnsContext((c) => c.columnSizes); - const columnSizesPercent = useColumnsContext((c) => c.columnSizesPercent); - - const { startResize, resizeColumn, addResizer, resetColumns } = - useColumnsActions(); - - useEffect(() => { - resetColumns([1, 3], tableRef); - }, [tableRef]); + const { + columnSizes, + columnSizesPercent, + startResize, + resizeColumn, + addResizer, + } = useColumns({ + tableRef, + initialRatios: [1, 3], + deps: [toolsPanelOpen], + }); const handleFetchMore = useCallback(() => { fetchMore(); @@ -84,11 +87,7 @@ export const TranslationsList = () => { } return ( - + {columnSizes.slice(0, -1).map((w, i) => { const left = columnSizes.slice(0, i + 1).reduce((a, b) => a + b, 0); return ( @@ -127,7 +126,7 @@ export const TranslationsList = () => { return (
* + * { - margin-left: ${({ theme }) => theme.spacing(2)}; - } -`; - -const easeIn = keyframes` - 0% { - opacity: 0; - } -`; - -const StyledContent = styled('div')` - display: flex; - align-items: center; - box-sizing: border-box; - transition: background-color 300ms ease-in-out, visibility 0ms; - padding: ${({ theme }) => theme.spacing(0, 1, 0, 2)}; - pointer-events: all; - border-radius: 6px; - height: 40px; - max-width: 100%; - background-color: ${({ theme }) => - alpha(theme.palette.background.paper, 0.9)}; - - @supports (backdrop-filter: blur()) or (-webkit-backdrop-filter: blur()) or - (-moz-backdrop-filter: blur()) { - background-color: ${({ theme }) => - alpha(theme.palette.background.paper, 0.5)}; - -webkit-backdrop-filter: blur(7px); - -moz-backdrop-filter: blur(7px); - backdrop-filter: blur(7px); - } - -webkit-box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); - box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.25); - - .icon { - opacity: 0.5; - cursor: pointer; - margin-left: ${({ theme }) => theme.spacing(1)}; - transition: all 300ms ease-in-out; - animation: ${easeIn} 0.2s ease-in; - pointer-events: all; - } - - .hoverHidden { - opacity: 0.2; - } - - &:hover .icon { - opacity: 1; - } - - &:hover .items { - opacity: 1; - } - - &:hover .hoverHidden { - opacity: 1; - } - - &.contentEmpty { - opacity: 0; - } - - &.contentCollapsed { - -webkit-backdrop-filter: none; - backdrop-filter: none; - background: transparent; - -webkit-box-shadow: none; - box-shadow: none; - pointer-events: none; - } -`; - -const StyledItem = styled('span')` - display: flex; - align-items: center; - gap: 2px; - animation: ${easeIn} 0.2s ease-in; - & > * + * { - margin-left: 0.4em; - } -`; - -const StyledItemContent = styled(Typography)` - font-size: 15px; -`; - -const StyledHelp = styled(Help)` - opacity: 0.5; - cursor: pointer; - margin-left: ${({ theme }) => theme.spacing(1)}; - transition: all 300ms ease-in-out; - animation: ${easeIn} 0.2s ease-in; - pointer-events: all; -`; - -const StyledClose = styled(Close)` - opacity: 0.5; - cursor: pointer; - margin-left: ${({ theme }) => theme.spacing(1)}; - transition: all 300ms ease-in-out; - animation: ${easeIn} 0.2s ease-in; - pointer-events: all; - - .hoverHidden { - opacity: 0.2; - } -`; - -export const TranslationsShortcuts = () => { - const [collapsed, setCollapsed] = useHideShortcuts(); - - const toggleCollapse = () => { - setCollapsed(!collapsed); - }; - - const cursorKeyId = useTranslationsSelector((c) => c.cursor?.keyId); - const cursorLanguage = useTranslationsSelector((c) => c.cursor?.language); - const cursorMode = useTranslationsSelector((c) => c.cursor?.mode); - - const translations = useTranslationsSelector((c) => c.translations); - const baseLanguage = useTranslationsSelector((c) => - c.languages?.find((l) => l.base) - ); - - const elementsRef = useTranslationsSelector((c) => c.elementsRef); - - const [availableActions, setAvailableActions] = useState< - ShortcutsArrayType[] - >([]); - const { getAvailableActions } = useTranslationsShortcuts(); - const { satisfiesLanguageAccess } = useProjectPermissions(); - - const onFocusChange = useDebouncedCallback( - () => { - setAvailableActions(getAvailableActions()); - }, - 100, - { maxWait: 200 } - ); - - useEffect(() => { - onFocusChange(); - document.body.addEventListener('focus', onFocusChange, true); - document.body.addEventListener('blur', onFocusChange, true); - return () => { - document.body.removeEventListener('focus', onFocusChange, true); - document.body.removeEventListener('blur', onFocusChange, true); - }; - }, [useDebouncedCallback]); - - const getCellNextState = (keyId: number, language: string | undefined) => { - if (language) { - const state = translations?.find((t) => t.keyId === keyId)?.translations[ - language - ]?.state; - return state && TRANSLATION_STATES[state]?.next; - } - }; - - const getActionTranslation = (action: keyof typeof KEY_MAP) => { - switch (action) { - case 'CHANGE_STATE': { - const focusedCell = getCurrentlyFocused(elementsRef.current); - const nextState = - focusedCell && - getCellNextState(focusedCell.keyId, focusedCell.language); - return nextState && TRANSLATION_STATES[nextState]?.translation; - } - case 'MOVE': - return ; - case 'EDIT': - return ; - } - }; - - const cursorKeyIdNextState = - cursorKeyId && getCellNextState(cursorKeyId, cursorLanguage); - - const getEditorShortcuts = () => [ - { - name: , - formula: formatShortcut('Enter'), - }, - { - name: , - formula: formatShortcut(`${getMetaName()} + Enter`), - }, - { - name: - cursorKeyIdNextState && - TRANSLATION_STATES[cursorKeyIdNextState]?.translation, - formula: formatShortcut(`${getMetaName()} + E`), - }, - { - name: cursorLanguage != baseLanguage?.tag && - satisfiesLanguageAccess('translations.view', baseLanguage?.id) && ( - - ), - formula: IS_MAC - ? formatShortcut(`${getMetaName()} + Shift + S`) - : formatShortcut(`${getMetaName()} + Insert`), - }, - ]; - - const editorIsActive = - cursorMode === 'editor' && - cursorLanguage && - document.activeElement?.className === 'CodeMirror-code'; - - const items = ( - (editorIsActive - ? getEditorShortcuts() - : availableActions.map(([action, keys]) => ({ - name: getActionTranslation(action as any), - formula: keys.map((f, i) => ( - {formatShortcut(f)} - )), - }))) as any[] - ).filter((i) => i.name); - - return ( - - - - {!collapsed && - items.map((item, i) => { - return ( - - - {item.name} - - - {item.formula} - - - ); - })} - - {!collapsed ? ( - - ) : ( - - )} - - - ); -}; diff --git a/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx b/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx index 00de726133..42e089d5a7 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/CellLanguage.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { styled, Box } from '@mui/material'; import { components } from 'tg.service/apiSchema.generated'; -import { CircledLanguageIcon } from 'tg.component/languages/CircledLanguageIcon'; import { CellStateBar } from '../cell/CellStateBar'; +import { FlagImage } from 'tg.component/languages/FlagImage'; type LanguageModel = components['schemas']['LanguageModel']; @@ -12,9 +12,7 @@ const StyledContent = styled('div')` align-items: center; padding: 8px 12px; flex-shrink: 0; - & > * + * { - margin-left: 5px; - } + gap: 8px; `; type Props = { @@ -32,7 +30,7 @@ export const CellLanguage: React.FC = ({ return ( <> - + {language.name} diff --git a/webapp/src/views/projects/translations/TranslationsTable/CellTranslation.tsx b/webapp/src/views/projects/translations/TranslationsTable/CellTranslation.tsx index 46f54dd093..92684a9087 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/CellTranslation.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/CellTranslation.tsx @@ -1,64 +1,31 @@ -import React, { useRef } from 'react'; +import { useRef } from 'react'; import clsx from 'clsx'; - import { components } from 'tg.service/apiSchema.generated'; -import { useEditableRow } from '../useEditableRow'; -import { TranslationVisual } from '../TranslationVisual'; -import { useTranslationsActions } from '../context/TranslationsContext'; + import { CELL_CLICKABLE, CELL_PLAIN, CELL_RAISED, StyledCell, } from '../cell/styles'; +import { useTranslationCell } from '../useTranslationCell'; import { CellStateBar } from '../cell/CellStateBar'; -import { ControlsTranslation } from '../cell/ControlsTranslation'; -import { TranslationOpened } from '../TranslationOpened'; -import { TranslationFlags } from '../cell/TranslationFlags'; -import { StateInType } from 'tg.constants/translationStates'; -import { styled } from '@mui/material'; +import { TranslationRead } from './TranslationRead'; +import { TranslationWrite } from './TranslationWrite'; type LanguageModel = components['schemas']['LanguageModel']; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; type TranslationViewModel = components['schemas']['TranslationViewModel']; -const StyledContainer = styled(StyledCell)` - display: flex; - flex-direction: column; - position: relative; -`; - -const StyledTranslationOpened = styled(TranslationOpened)` - overflow: hidden; - display: flex; - flex-direction: column; - flex-grow: 1; - padding-left: 4px; -`; - -const StyledAutoIndicator = styled(TranslationFlags)` - height: 0; - position: relative; -`; - -const StyledTranslation = styled('div')` - flex-grow: 1; - margin: ${({ theme }) => theme.spacing(1.5, 1.5, 1, 1.5)}; -`; - type Props = { data: KeyWithTranslationsModel; language: LanguageModel; colIndex?: number; onResize?: (colIndex: number) => void; - editEnabled: boolean; - stateChangeEnabled: boolean; width?: number | string; - cellPosition: string; active: boolean; lastFocusable: boolean; - containerRef: React.RefObject; className?: string; }; @@ -67,13 +34,9 @@ export const CellTranslation: React.FC = ({ language, colIndex, onResize, - editEnabled, - stateChangeEnabled, width, - cellPosition, active, lastFocusable, - containerRef, className, }) => { const cellRef = useRef(null); @@ -81,116 +44,49 @@ export const CellTranslation: React.FC = ({ const translation = data.translations[language.tag] as | TranslationViewModel | undefined; - const state = translation?.state || 'UNTRANSLATED'; - - const disabled = state === 'DISABLED'; - const editable = editEnabled && !disabled; - const { - isEditing, - editVal, - value, - setValue, - handleOpen, - handleClose, - handleInsertBase, - handleSave, - autofocus, - handleModeChange, - isEditingRow, - } = useEditableRow({ - keyId: data.keyId, - keyName: data.keyName, - defaultVal: translation?.text || '', - language: language.tag, - cellRef, + const tools = useTranslationCell({ + keyData: data, + language: language, + cellRef: cellRef, }); - const { setTranslationState } = useTranslationsActions(); - - const handleStateChange = (state: StateInType) => { - setTranslationState({ - keyId: data.keyId, - translationId: translation?.id as number, - language: language.tag as string, - state, - }); - }; + const { isEditing, editEnabled: canEditTranslation } = tools; const handleResize = () => { onResize?.(colIndex || 0); }; - const showAllLines = isEditing || (language.base && isEditingRow); + const state = translation?.state || 'UNTRANSLATED'; + + const disabled = state === 'DISABLED'; + const editable = canEditTranslation && !disabled; return ( - handleOpen('editor') : undefined} tabIndex={0} ref={cellRef} data-cy="translations-table-cell-translation" data-cy-lang={language.tag} > - {editVal ? ( - setValue(v as string)} - onSave={() => handleSave()} - onCmdSave={() => handleSave('EDIT_NEXT')} - onInsertBase={handleInsertBase} - onCancel={handleClose} - autofocus={autofocus} - state={state} - onStateChange={handleStateChange} - mode={editVal.mode} - onModeChange={handleModeChange} - editEnabled={editable} - stateChangeEnabled={stateChangeEnabled} - cellRef={containerRef} - cellPosition={cellPosition} - /> + + {isEditing ? ( + ) : ( - <> - -
- -
- - -
- - handleOpen('editor')} - editEnabled={editable} - state={state} - stateChangeEnabled={stateChangeEnabled} - onStateChange={handleStateChange} - onComments={() => handleOpen('comments')} - commentsCount={translation?.commentCount} - unresolvedCommentCount={translation?.unresolvedCommentCount} - lastFocusable={lastFocusable} - active={active} - /> - + )} - - -
+ ); }; diff --git a/webapp/src/views/projects/translations/TranslationsTable/RowTable.tsx b/webapp/src/views/projects/translations/TranslationsTable/RowTable.tsx index b2c56f7118..92f1d9fad0 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/RowTable.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/RowTable.tsx @@ -13,7 +13,7 @@ import { CELL_SPACE_BOTTOM, CELL_SPACE_TOP } from '../cell/styles'; type LanguageModel = components['schemas']['LanguageModel']; const StyledContainer = styled('div')` - display: flex; + display: grid; border: 1px solid ${({ theme }) => theme.palette.divider1}; border-width: 1px 0px 0px 0px; position: relative; @@ -48,8 +48,7 @@ export const RowTable: React.FC = React.memo(function RowTable({ bannerBefore, bannerAfter, }) { - const { satisfiesPermission, satisfiesLanguageAccess } = - useProjectPermissions(); + const { satisfiesPermission } = useProjectPermissions(); const [hover, setHover] = useState(false); const [focus, setFocus] = useState(false); const active = hover || focus; @@ -60,8 +59,6 @@ export const RowTable: React.FC = React.memo(function RowTable({ const containerRef = useRef(null); - const colSizesNum = columnSizes.map((val) => Number(val.replace('%', ''))); - const allClassName = clsx({ [CELL_SPACE_TOP]: bannerBefore, [CELL_SPACE_BOTTOM]: bannerAfter, @@ -75,40 +72,19 @@ export const RowTable: React.FC = React.memo(function RowTable({ onBlur={() => setFocus(false)} data-cy="translations-row" className={clsx(data.deleted && 'deleted')} + style={{ + gridTemplateColumns: columnSizes.join(' '), + width: `calc(${columnSizes.join(' + ')})`, + }} > {languages.map((language, index) => { - const allWidth = 100 - colSizesNum[0]; - - const prevWidth = colSizesNum - .slice(1, index + 1) - .reduce((prev, cur) => prev + cur, 0); - - const cellWidth = Number(colSizesNum[index + 1]); - - // calculate arrow position for popup - const cellPosition = `${ - ((prevWidth + cellWidth / 2) / allWidth) * 100 - }%`; - - const canChangeState = satisfiesLanguageAccess( - 'translations.state-edit', - language.id - ); - - const canEdit = satisfiesLanguageAccess( - 'translations.edit', - language.id - ); - return ( = React.memo(function RowTable({ language={language} colIndex={index} onResize={onResize} - stateChangeEnabled={canChangeState} - editEnabled={canEdit} - width={columnSizes[index + 1]} - cellPosition={cellPosition} active={relaxedActive} // render last focusable button on last item, so it's focusable lastFocusable={index === languages.length - 1} - containerRef={containerRef} className={allClassName} /> ); diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx new file mode 100644 index 0000000000..3ed7450d4f --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationRead.tsx @@ -0,0 +1,116 @@ +import clsx from 'clsx'; +import { styled } from '@mui/material'; + +import { useTranslationCell } from '../useTranslationCell'; +import { TranslationVisual } from '../translationVisual/TranslationVisual'; +import { ControlsTranslation } from '../cell/ControlsTranslation'; +import { TranslationFlags } from '../cell/TranslationFlags'; + +const StyledContainer = styled('div')` + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: + 'translation translation ' + 'flags controls '; + + .flags { + padding: 0px 12px 4px 16px; + grid-area: flags; + display: flex; + align-items: center; + } + + .controls { + padding: 12px 12px 12px 12px; + grid-area: controls; + justify-self: end; + } +`; + +const StyledTranslation = styled('div')` + grid-area: translation; + min-height: 23px; + margin: 8px 12px 0px 16px; + position: relative; +`; + +type Props = { + colIndex?: number; + width?: number | string; + active: boolean; + lastFocusable: boolean; + className?: string; + tools: ReturnType; +}; + +export const TranslationRead: React.FC = ({ + width, + active, + lastFocusable, + className, + tools, +}) => { + const { + isEditing, + handleOpen, + handleClose, + setState: handleStateChange, + translation, + language, + canChangeState, + editEnabled, + keyData, + } = tools; + + const toggleEdit = () => { + if (isEditing) { + handleClose(); + } else { + handleOpen(); + } + }; + + const state = translation?.state || 'UNTRANSLATED'; + + const disabled = state === 'DISABLED'; + const editable = editEnabled && !disabled; + + return ( + toggleEdit() : undefined} + > + + + + + handleOpen()} + onComments={() => handleOpen('comments')} + commentsCount={translation?.commentCount} + unresolvedCommentCount={translation?.unresolvedCommentCount} + stateChangeEnabled={canChangeState} + editEnabled={editable} + state={state} + onStateChange={handleStateChange} + active={active} + lastFocusable={lastFocusable} + className="controls" + /> + + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx new file mode 100644 index 0000000000..97bc2fe93f --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx @@ -0,0 +1,157 @@ +import { useRef, useState } from 'react'; +import { EditorView } from 'codemirror'; +import { styled } from '@mui/material'; +import { Placeholder } from '@tginternal/editor'; + +import { ControlsEditorMain } from '../cell/ControlsEditorMain'; +import { ControlsEditorSmall } from '../cell/ControlsEditorSmall'; +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { useTranslationCell } from '../useTranslationCell'; +import { TranslationEditor } from '../TranslationEditor'; +import { MissingPlaceholders } from '../cell/MissingPlaceholders'; +import { useMissingPlaceholders } from '../cell/useMissingPlaceholders'; +import { TranslationVisual } from '../translationVisual/TranslationVisual'; +import { ControlsEditorReadOnly } from '../cell/ControlsEditorReadOnly'; +import { useBaseTranslation } from '../useBaseTranslation'; + +const StyledContainer = styled('div')` + display: grid; +`; + +const StyledEditor = styled('div')` + padding: 12px 12px 12px 16px; +`; + +const StyledBottom = styled('div')` + display: flex; + padding: 4px 12px 12px 16px; + flex-wrap: wrap; + gap: 14px; + align-items: center; +`; + +const StyledControls = styled('div')` + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + flex-grow: 1; +`; + +type Props = { + tools: ReturnType; +}; + +export const TranslationWrite: React.FC = ({ tools }) => { + const { + value, + keyData, + translation, + language, + canChangeState, + setState, + handleSave, + handleClose, + handleInsertBase, + editEnabled, + disabled, + } = tools; + const editVal = tools.editVal!; + const state = translation?.state || 'UNTRANSLATED'; + const activeVariant = editVal.activeVariant; + + const [mode, setMode] = useState<'placeholders' | 'syntax'>('placeholders'); + const editorRef = useRef(null); + const baseLanguage = useTranslationsSelector((c) => c.baseLanguage); + const nested = Boolean(editVal.value.parameter); + + const baseTranslation = useBaseTranslation( + activeVariant, + keyData.translations[baseLanguage]?.text, + keyData.keyIsPlural + ); + + const missingPlaceholders = useMissingPlaceholders({ + baseTranslation, + currentTranslation: value, + nested, + }); + + const handleModeToggle = () => { + setMode((mode) => (mode === 'syntax' ? 'placeholders' : 'syntax')); + }; + + const handlePlaceholderClick = (placeholder: Placeholder) => { + if (editorRef.current) { + const state = editorRef.current.state; + const selection = state.selection; + const placeholderText = placeholder.normalizedValue || ''; + const transactions = selection.ranges.map((value) => + state.update({ + changes: { + from: value.from, + to: value.to, + insert: placeholderText, + }, + selection: { + anchor: value.from + placeholderText.length, + }, + }) + ); + editorRef.current.update(transactions); + } + }; + + return ( + + e.preventDefault()}> + {editEnabled ? ( + + ) : ( + + )} + + + + {Boolean(missingPlaceholders.length) && ( + + )} + + + e.preventDefault(), + }} + state={state} + mode={mode} + isBaseLanguage={language.base} + stateChangeEnabled={canChangeState} + onInsertBase={editEnabled ? handleInsertBase : undefined} + onStateChange={setState} + onModeToggle={editEnabled ? handleModeToggle : undefined} + /> + {editEnabled ? ( + handleClose(true)} + /> + ) : ( + handleClose(true)} /> + )} + + + + ); +}; diff --git a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx index ff476594b3..40a8b70881 100644 --- a/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx +++ b/webapp/src/views/projects/translations/TranslationsTable/TranslationsTable.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import ReactList from 'react-list'; -import { styled } from '@mui/material'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Portal, styled, useMediaQuery } from '@mui/material'; import { T } from '@tolgee/react'; import { @@ -12,32 +11,85 @@ import { CellLanguage } from './CellLanguage'; import { RowTable } from './RowTable'; import { NamespaceBanner } from '../Namespace/NamespaceBanner'; import { useNsBanners } from '../context/useNsBanners'; -import { - useColumnsActions, - useColumnsContext, -} from '../context/ColumnsContext'; import { NAMESPACE_BANNER_SPACING } from '../cell/styles'; +import { ReactList } from 'tg.component/reactList/ReactList'; +import clsx from 'clsx'; +import { useScrollStatus } from './useScrollStatus'; +import { useColumns } from '../useColumns'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; + +const ARROW_SIZE = 50; const StyledContainer = styled('div')` position: relative; - margin: 10px 0px 100px 0px; + display: grid; + margin: 10px 0px 0px -0px; border-left: 0px; border-right: 0px; background: ${({ theme }) => theme.palette.background.default}; flex-grow: 1; + + &::before { + content: ''; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(90deg, #0000002c, transparent); + top: 0px; + left: 0px; + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 100ms ease-in-out; + } + + &::after { + content: ''; + height: 100%; + position: absolute; + width: 6px; + background-image: linear-gradient(-90deg, #0000002c, transparent); + top: 0px; + right: 0px; + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 100ms ease-in-out; + } + + &.scrollLeft { + &::before { + opacity: 1; + } + } + + &.scrollRight { + &::after { + opacity: 1; + } + } +`; + +const StyledVerticalScroll = styled('div')` + overflow-x: scroll; + overflow-y: hidden; + scroll-behavior: smooth; +`; + +const StyledContent = styled('div')` + position: relative; `; const StyledHeaderRow = styled('div')` - border: 1px solid ${({ theme }) => theme.palette.divider1}; - border-width: 1px 0px 1px 0px; position: sticky; background: ${({ theme }) => theme.palette.background.default}; top: 0px; margin-bottom: -1px; - display: flex; + display: grid; `; const StyledHeaderCell = styled('div')` + border-top: 1px solid ${({ theme }) => theme.palette.divider1}; box-sizing: border-box; display: flex; flex-grow: 0; @@ -48,9 +100,53 @@ const StyledHeaderCell = styled('div')` } `; -export const TranslationsTable = () => { +const StyledScrollArrow = styled('div')` + position: fixed; + top: 50vh; + width: ${ARROW_SIZE / 2}px; + height: ${ARROW_SIZE}px; + z-index: 5; + cursor: pointer; + border: 1px solid ${({ theme }) => theme.palette.divider1}; + background: ${({ theme }) => theme.palette.background.default}; + opacity: 0; + transition: opacity 150ms ease-in-out; + pointer-events: none; + + display: flex; + align-items: center; + justify-content: center; + + &.right { + border-radius: ${ARROW_SIZE}px 0px 0px ${ARROW_SIZE}px; + padding-left: 4px; + border-right: none; + } + &.left { + border-radius: 0px ${ARROW_SIZE}px ${ARROW_SIZE}px 0px; + padding-right: 4px; + border-left: none; + } + &.scrollLeft { + opacity: 1; + pointer-events: all; + } + + &.scrollRight { + opacity: 1; + pointer-events: all; + } +`; + +type Props = { + toolsPanelOpen: boolean; +}; + +export const TranslationsTable = ({ toolsPanelOpen }: Props) => { const tableRef = useRef(null); const reactListRef = useRef(null); + const verticalScrollRef = useRef(null); + const sidePanelOpen = useTranslationsSelector((c) => c.sidePanelOpen); const { fetchMore, registerList, unregisterList } = useTranslationsActions(); const translations = useTranslationsSelector((v) => v.translations); @@ -60,13 +156,6 @@ export const TranslationsTable = () => { const languages = useTranslationsSelector((v) => v.languages); const isFetchingMore = useTranslationsSelector((v) => v.isFetchingMore); const hasMoreToFetch = useTranslationsSelector((v) => v.hasMoreToFetch); - const cursorKeyId = useTranslationsSelector((c) => c.cursor?.keyId); - - const columnSizes = useColumnsContext((c) => c.columnSizes); - const columnSizesPercent = useColumnsContext((c) => c.columnSizesPercent); - - const { startResize, resizeColumn, addResizer, resetColumns } = - useColumnsActions(); const languageCols = useMemo(() => { if (languages && translationsLanguages) { @@ -85,12 +174,18 @@ export const TranslationsTable = () => { [translationsLanguages] ); - useEffect(() => { - resetColumns( - columns.map(() => 1), - tableRef - ); - }, [languageCols, tableRef]); + const { + columnSizes, + columnSizesPercent, + startResize, + resizeColumn, + addResizer, + } = useColumns({ + tableRef, + initialRatios: columns.map(() => 1), + minSize: 300, + deps: [toolsPanelOpen], + }); const handleFetchMore = useCallback(() => { fetchMore(); @@ -112,96 +207,151 @@ export const TranslationsTable = () => { return null; } + const fullWidth = columnSizes.reduce((a, b) => a + b, 0); + + const [scrollLeft, scrollRight] = useScrollStatus(verticalScrollRef, [ + fullWidth, + ]); + + function handleScroll(direction: 'left' | 'right') { + const element = verticalScrollRef.current; + if (element) { + const position = element.scrollLeft; + element.scrollTo({ + left: position + (direction === 'left' ? -350 : +350), + }); + } + } + + const [tablePosition, setTablePosition] = useState({ left: 0, right: 0 }); + useEffect(() => { + const position = tableRef.current?.getBoundingClientRect(); + if (position) { + const left = position?.left; + const right = window.innerWidth - position?.right; + setTablePosition({ left, right }); + } + }, [tableRef.current, fullWidth, sidePanelOpen]); + const hasMinimalHeight = useMediaQuery('(min-height: 400px)'); + return ( - - {columns.map((tag, i) => { - const language = languages?.find((lang) => lang.tag === tag); - return tag ? ( - language && ( - + handleScroll('right')} + > + + + handleScroll('left')} + > + + + + )} + + + + {columns.map((tag, i) => { + const language = languages?.find((lang) => lang.tag === tag); + return tag ? ( + language && ( + + + + ) + ) : ( + + + + ); + })} + + {columnSizes.slice(0, -1).map((w, i) => { + const left = columnSizes.slice(0, i + 1).reduce((a, b) => a + b, 0); + return ( + - - - ) - ) : ( - - - - ); - })} - - {columnSizes.slice(0, -1).map((w, i) => { - const left = columnSizes.slice(0, i + 1).reduce((a, b) => a + b, 0); - return ( - resizeColumn(i, size)} - passResizeCallback={(callback) => addResizer(i, callback)} - /> - ); - })} - - { - return cache[index] || 84; - }} - // @ts-ignore - scrollParentGetter={() => window} - length={translations.length} - useTranslate3d - itemRenderer={(index) => { - const row = translations[index]; - const isLast = index === translations.length - 1; - if (isLast && !isFetchingMore && hasMoreToFetch) { - handleFetchMore(); - } - - const nsBannerAfter = nsBanners.find((b) => b.row === index + 1); - const nsBanner = nsBanners.find((b) => b.row === index); - return ( -
- {nsBanner && ( - - )} - resizeColumn(i, size)} + passResizeCallback={(callback) => addResizer(i, callback)} /> -
- ); - }} - /> + ); + })} + + { + return cache[index] || 84; + }} + // @ts-ignore + scrollParentGetter={() => window} + length={translations.length} + useTranslate3d + itemRenderer={(index) => { + const row = translations[index]; + const isLast = index === translations.length - 1; + if (isLast && !isFetchingMore && hasMoreToFetch) { + handleFetchMore(); + } + + const nsBannerAfter = nsBanners.find((b) => b.row === index + 1); + const nsBanner = nsBanners.find((b) => b.row === index); + return ( +
+ {nsBanner && ( + + )} + +
+ ); + }} + /> +
+ ); }; diff --git a/webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts b/webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts new file mode 100644 index 0000000000..085db7a692 --- /dev/null +++ b/webapp/src/views/projects/translations/TranslationsTable/useScrollStatus.ts @@ -0,0 +1,42 @@ +import { RefObject, useEffect, useState } from 'react'; + +export const useScrollStatus = ( + ref: RefObject, + deps?: React.DependencyList | undefined +) => { + const [offsets, setOffests] = useState<[boolean, boolean]>([false, false]); + const [recalculateScrollOffsets] = useState(() => () => { + const element = ref.current; + if (element) { + const scrollLeft = element?.scrollLeft; + const scrollWidth = element?.scrollWidth; + const offsetWidth = element?.offsetWidth; + const isOffsetLeft = scrollLeft !== 0; + const isOffestRight = scrollLeft + offsetWidth < scrollWidth - 1; + setOffests((current) => { + const [oLeft, oRight] = current; + if (oLeft !== isOffsetLeft || oRight !== isOffestRight) { + return [isOffsetLeft, isOffestRight] as const; + } + return current; + }); + } + }); + + useEffect(() => { + ref.current?.addEventListener('scroll', recalculateScrollOffsets); + return () => + ref.current?.removeEventListener('scroll', recalculateScrollOffsets); + }, [ref]); + + useEffect(() => { + addEventListener('resize', recalculateScrollOffsets); + return () => removeEventListener('resize', recalculateScrollOffsets); + }, [ref]); + + useEffect(() => { + recalculateScrollOffsets(); + }, deps); + + return offsets; +}; diff --git a/webapp/src/views/projects/translations/TranslationsToolbar.tsx b/webapp/src/views/projects/translations/TranslationsToolbar.tsx index 7c8ef2723f..80a4c34395 100644 --- a/webapp/src/views/projects/translations/TranslationsToolbar.tsx +++ b/webapp/src/views/projects/translations/TranslationsToolbar.tsx @@ -1,13 +1,13 @@ import clsx from 'clsx'; import { useEffect, useState } from 'react'; import { useTranslate } from '@tolgee/react'; -import { IconButton, Portal, styled, Tooltip, useTheme } from '@mui/material'; +import { IconButton, Portal, styled, Tooltip } from '@mui/material'; import { KeyboardArrowUp } from '@mui/icons-material'; import { useDebouncedCallback } from 'use-debounce'; import { useTranslationsSelector } from './context/TranslationsContext'; -import { TranslationsShortcuts } from './TranslationsShortcuts'; import { BatchOperations } from './BatchOperations/BatchOperations'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; const StyledContainer = styled('div')` z-index: ${({ theme }) => theme.zIndex.drawer}; @@ -16,7 +16,7 @@ const StyledContainer = styled('div')` align-items: stretch; justify-content: space-between; bottom: 0px; - left: 44px; + padding-left: 44px; pointer-events: none; `; @@ -75,15 +75,10 @@ const StyledStretcher = styled('div')` overflow: hidden; `; -type Props = { - width: number; -}; - -export const TranslationsToolbar: React.FC = ({ width }) => { +export const TranslationsToolbar: React.FC = () => { const [index, setIndex] = useState(1); const [isMouseOver, setIsMouseOver] = useState(false); const [selectionOpen, setSelectionOpen] = useState(false); - const theme = useTheme(); const [toolbarVisible, setToolbarVisible] = useState(false); const { t } = useTranslate(); const totalCount = useTranslationsSelector((c) => c.translationsTotal || 0); @@ -133,19 +128,20 @@ export const TranslationsToolbar: React.FC = ({ width }) => { const counterContent = `${index} / ${totalCount}`; - return width ? ( + const rightPanelWidth = useGlobalContext((c) => c.rightPanelWidth); + + return ( setIsMouseOver(false)} /> - {!selectionOpen && } = ({ width }) => { - ) : null; + ); }; diff --git a/webapp/src/views/projects/translations/TranslationsView.tsx b/webapp/src/views/projects/translations/TranslationsView.tsx index 9d07361b4a..e3342a210b 100644 --- a/webapp/src/views/projects/translations/TranslationsView.tsx +++ b/webapp/src/views/projects/translations/TranslationsView.tsx @@ -2,7 +2,6 @@ import { Translations } from './Translations'; import { TranslationsContextProvider } from './context/TranslationsContext'; import { useProject } from 'tg.hooks/useProject'; import { HeaderNsContext } from './context/HeaderNsContext'; -import { ColumnsContext } from './context/ColumnsContext'; export const TranslationsView = () => { const project = useProject(); @@ -14,9 +13,7 @@ export const TranslationsView = () => { updateLocalStorageLanguages > - - - + ); diff --git a/webapp/src/views/projects/translations/cell/CellStateBar.tsx b/webapp/src/views/projects/translations/cell/CellStateBar.tsx index 5a12f973c9..f81c66a994 100644 --- a/webapp/src/views/projects/translations/cell/CellStateBar.tsx +++ b/webapp/src/views/projects/translations/cell/CellStateBar.tsx @@ -10,6 +10,7 @@ const StyledStateHover = styled('div')` position: absolute; width: 12px; height: 100%; + z-index: 1; `; const StyledState = styled('div')` diff --git a/webapp/src/views/projects/translations/cell/ControlsEditor.tsx b/webapp/src/views/projects/translations/cell/ControlsEditor.tsx deleted file mode 100644 index 62072d8107..0000000000 --- a/webapp/src/views/projects/translations/cell/ControlsEditor.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from 'react'; -import { T } from '@tolgee/react'; -import { Button, styled } from '@mui/material'; -import { CameraAlt, ContentCopy } from '@mui/icons-material'; - -import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { components } from 'tg.service/apiSchema.generated'; -import { StateInType } from 'tg.constants/translationStates'; -import { ControlsButton } from './ControlsButton'; -import { StateTransitionButtons } from './StateTransitionButtons'; -import { useTranslationsSelector } from '../context/TranslationsContext'; -import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; - -type State = components['schemas']['TranslationViewModel']['state']; - -const StyledLeftPart = styled('div')` - display: flex; - align-items: flex-start; - overflow: hidden; - padding: ${({ theme }) => theme.spacing(1, 1.5, 1.5, 1.5)}; - gap: 10px; -`; - -const StyledRightPart = styled('div')` - display: flex; - align-items: center; - padding: ${({ theme }) => theme.spacing(1, 1.5, 1.5, 0)}; - gap: 8px; -`; - -const StyledRightestPart = styled('div')` - display: flex; - align-items: center; - margin-left: auto; - margin-right: 5px; - padding: ${({ theme }) => theme.spacing(1, 1.5, 1.5, 0)}; - gap: 8px; -`; - -type ControlsProps = { - state?: State; - isBaseLanguage?: boolean; - stateChangeEnabled?: boolean; - onSave?: () => void; - onCancel?: () => void; - onInsertBase?: () => void; - onScreenshots?: () => void; - onStateChange?: (state: StateInType) => void; - screenshotRef?: React.Ref; - screenshotsPresent?: boolean; -}; - -export const ControlsEditor: React.FC = ({ - state, - isBaseLanguage, - stateChangeEnabled, - onSave, - onCancel, - onInsertBase, - onScreenshots, - onStateChange, - screenshotRef, - screenshotsPresent, -}) => { - // right section - const displayTransitionButtons = state && stateChangeEnabled; - const displayScreenshots = onScreenshots; - const displayRightPart = displayTransitionButtons || displayScreenshots; - const { satisfiesLanguageAccess } = useProjectPermissions(); - const baseLanguage = useTranslationsSelector((c) => - c.languages?.find((l) => l.base) - ); - const displayInsertBase = - !isBaseLanguage && - satisfiesLanguageAccess('translations.view', baseLanguage?.id); - - const isEditLoading = useTranslationsSelector((c) => c.isEditLoading); - - return ( - <> - - - - - - - - {displayRightPart && ( - - {displayTransitionButtons && ( - - )} - {displayScreenshots && ( - } - data-cy="translations-cell-screenshots-button" - > - - - )} - - )} - - {displayInsertBase && ( - - { - e.preventDefault(); - }} - color="default" - data-cy="translations-cell-insert-base-button" - tooltip={} - > - - - - )} - - ); -}; diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx new file mode 100644 index 0000000000..2628e3b552 --- /dev/null +++ b/webapp/src/views/projects/translations/cell/ControlsEditorMain.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { T } from '@tolgee/react'; +import { Button, styled } from '@mui/material'; + +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { useTranslationsSelector } from '../context/TranslationsContext'; + +const StyledContainer = styled('div')` + display: flex; + align-items: center; + gap: 12px; +`; + +type ControlsProps = { + onSave?: () => void; + onCancel?: () => void; + className?: string; +}; + +export const ControlsEditorMain: React.FC = ({ + onSave, + onCancel, + className, +}) => { + const isEditLoading = useTranslationsSelector((c) => c.isEditLoading); + + return ( + + + + + + + ); +}; diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorReadOnly.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorReadOnly.tsx new file mode 100644 index 0000000000..9c1a80abbe --- /dev/null +++ b/webapp/src/views/projects/translations/cell/ControlsEditorReadOnly.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { T } from '@tolgee/react'; +import { Button, styled } from '@mui/material'; + +const StyledContainer = styled('div')` + display: flex; + align-items: center; + gap: 12px; +`; + +type ControlsProps = { + onClose?: () => void; + className?: string; +}; + +export const ControlsEditorReadOnly: React.FC = ({ + onClose, + className, +}) => { + return ( + + + + ); +}; diff --git a/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx new file mode 100644 index 0000000000..65fce9b67e --- /dev/null +++ b/webapp/src/views/projects/translations/cell/ControlsEditorSmall.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { Box, styled } from '@mui/material'; +import { Code, ContentCopy } from '@mui/icons-material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { StateInType } from 'tg.constants/translationStates'; +import { ControlsButton } from './ControlsButton'; +import { StateTransitionButtons } from './StateTransitionButtons'; +import { useTranslationsSelector } from '../context/TranslationsContext'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; +import { useProject } from 'tg.hooks/useProject'; + +type State = components['schemas']['TranslationViewModel']['state']; + +const StyledContainer = styled(Box)` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const StyledIcons = styled('div')` + display: flex; + gap: 12px; + padding-right: 4px; +`; + +type ControlsProps = { + state?: State; + mode?: 'placeholders' | 'syntax'; + isBaseLanguage?: boolean; + stateChangeEnabled?: boolean; + onInsertBase?: () => void; + onStateChange?: (state: StateInType) => void; + onModeToggle?: () => void; + controlsProps?: React.ComponentProps; +}; + +export const ControlsEditorSmall: React.FC = ({ + state, + mode, + isBaseLanguage, + stateChangeEnabled, + onInsertBase, + onModeToggle, + onStateChange, + controlsProps, +}) => { + const project = useProject(); + const { t } = useTranslate(); + const displayTransitionButtons = state && stateChangeEnabled; + const { satisfiesLanguageAccess } = useProjectPermissions(); + const baseLanguage = useTranslationsSelector((c) => + c.languages?.find((l) => l.base) + ); + const displayInsertBase = + onInsertBase && + !isBaseLanguage && + satisfiesLanguageAccess('translations.view', baseLanguage?.id); + + const displayEditorMode = project.icuPlaceholders; + + return ( + + + {displayTransitionButtons && ( + + )} + {onModeToggle && displayEditorMode && ( + { + e.preventDefault(); + }} + color={mode === 'placeholders' ? 'default' : 'primary'} + data-cy="translations-cell-switch-mode" + tooltip={ + mode === 'placeholders' + ? t('translations_editor_switch_to_raw') + : t('translations_editor_switch_to_placeholders') + } + > + + + )} + + {displayInsertBase && ( + { + e.preventDefault(); + }} + color="default" + data-cy="translations-cell-insert-base-button" + tooltip={} + > + + + )} + + + ); +}; diff --git a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx index b1d57297fe..5da17c2198 100644 --- a/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx +++ b/webapp/src/views/projects/translations/cell/ControlsTranslation.tsx @@ -1,6 +1,6 @@ import React from 'react'; import clsx from 'clsx'; -import { Badge, styled } from '@mui/material'; +import { Badge, Box, styled } from '@mui/material'; import { Check, Comment, Edit } from '@mui/icons-material'; import { T } from '@tolgee/react'; @@ -12,17 +12,13 @@ import { CELL_HIGHLIGHT_ON_HOVER, CELL_SHOW_ON_HOVER } from './styles'; type State = components['schemas']['TranslationViewModel']['state']; -const StyledControlsWrapper = styled('div')` +const StyledControlsWrapper = styled(Box)` display: grid; box-sizing: border-box; - grid-area: controls; justify-content: end; - overflow: hidden; - min-height: 44px; - padding: 12px 14px 12px 12px; - margin-top: -16px; - margin-right: -8px; + padding: 0px 0px 0px 0px; gap: 4px; + margin: 0px 0px; `; const StyledStateButtons = styled('div')` @@ -68,6 +64,8 @@ type ControlsProps = { // render last focusable button lastFocusable: boolean; active?: boolean; + containerProps?: React.ComponentProps; + className?: string; }; export const ControlsTranslation: React.FC = ({ @@ -81,6 +79,7 @@ export const ControlsTranslation: React.FC = ({ unresolvedCommentCount, lastFocusable, active, + className, }) => { const spots: string[] = []; @@ -115,6 +114,7 @@ export const ControlsTranslation: React.FC = ({ gridTemplateAreas, gridTemplateColumns, }} + className={className} > {inDomTransitionButtons && ( diff --git a/webapp/src/views/projects/translations/cell/MissingPlaceholders.tsx b/webapp/src/views/projects/translations/cell/MissingPlaceholders.tsx new file mode 100644 index 0000000000..8de921fe1d --- /dev/null +++ b/webapp/src/views/projects/translations/cell/MissingPlaceholders.tsx @@ -0,0 +1,73 @@ +import { styled, useTheme } from '@mui/material'; +import { + Placeholder, + generatePlaceholdersStyle, + getVariantExample, +} from '@tginternal/editor'; +import { useMemo } from 'react'; +import { placeholderToElement } from '../translationVisual/placeholderToElement'; +import { T } from '@tolgee/react'; + +const StyledWrapper = styled('span')``; + +const StyledLabel = styled('span')` + margin-right: 8px; + font-size: 14px; + position: relative; + bottom: 1px; +`; + +type Props = { + placeholders: Placeholder[]; + onPlaceholderClick: (placeholder: Placeholder) => void; + locale: string; + variant: string | undefined; + className?: string; +}; + +export const MissingPlaceholders = ({ + placeholders, + onPlaceholderClick, + locale, + variant, + className, +}: Props) => { + const theme = useTheme(); + const StyledPlaceholdersWrapper = useMemo(() => { + return generatePlaceholdersStyle({ + styled, + colors: theme.palette.placeholders, + component: StyledWrapper, + }); + }, [theme.palette.placeholders]); + + const pluralExampleValue = useMemo(() => { + return getVariantExample(locale, variant ?? ''); + }, [locale, variant]); + + return ( + e.preventDefault()} + > + {Boolean(placeholders.length) && ( + <> + + + + {placeholders.map((value, i) => + placeholderToElement({ + placeholder: value, + key: i, + props: { + onClick: () => onPlaceholderClick(value), + style: { cursor: 'pointer' }, + }, + pluralExampleValue, + }) + )} + + )} + + ); +}; diff --git a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx index 359622f304..a671e14a78 100644 --- a/webapp/src/views/projects/translations/cell/TranslationFlags.tsx +++ b/webapp/src/views/projects/translations/cell/TranslationFlags.tsx @@ -13,7 +13,6 @@ type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; const StyledWrapper = styled('div')` - height: 0px; display: flex; gap: 2px; `; @@ -136,6 +135,7 @@ export const TranslationFlags: React.FC = ({ role="button" onClick={handleClearOutdated} data-cy="translations-outdated-clear-button" + className="clearButton" /> )} diff --git a/webapp/src/views/projects/translations/cell/styles.ts b/webapp/src/views/projects/translations/cell/styles.ts index 76bf308043..080c5e22a1 100644 --- a/webapp/src/views/projects/translations/cell/styles.ts +++ b/webapp/src/views/projects/translations/cell/styles.ts @@ -53,8 +53,12 @@ export const StyledCell = styled('div')<{ position?: PositionType }>` ${combine('&', CELL_PLAIN)} { scroll-margin-top: ${TOP_BAR_HEIGHT}px; + scroll-margin-left: 10px; + scroll-margin-right: 10px; position: relative; outline: 0; + container-type: inline-size; + display: grid; &:hover .${CELL_SHOW_ON_HOVER} { opacity: 1; diff --git a/webapp/src/views/projects/translations/cell/useMissingPlaceholders.ts b/webapp/src/views/projects/translations/cell/useMissingPlaceholders.ts new file mode 100644 index 0000000000..c17d71ba18 --- /dev/null +++ b/webapp/src/views/projects/translations/cell/useMissingPlaceholders.ts @@ -0,0 +1,51 @@ +import { Placeholder, getPlaceholders } from '@tginternal/editor'; +import { useMemo, useRef } from 'react'; +import { useProject } from 'tg.hooks/useProject'; + +export type Props = { + baseTranslation: string | undefined; + currentTranslation: string | undefined; + nested: boolean; +}; + +export const useMissingPlaceholders = ({ + baseTranslation, + currentTranslation, + nested, +}: Props) => { + const project = useProject(); + + if (!project.icuPlaceholders) { + return []; + } + + const basePlaceholders = useMemo(() => { + return getPlaceholders(baseTranslation || '', nested) ?? []; + }, [baseTranslation, nested]); + + const lastValidPlaceholders = useRef(); + + lastValidPlaceholders.current = useMemo(() => { + const newPlaceholders = getPlaceholders(currentTranslation || '', nested); + if (newPlaceholders === null) { + return lastValidPlaceholders.current; + } else { + return newPlaceholders; + } + }, [currentTranslation, nested]); + + return useMemo(() => { + const placeholdersMap = new Map(); + lastValidPlaceholders.current?.forEach((i) => { + const id = i.normalizedValue; + const value = placeholdersMap.get(id) ?? 0; + placeholdersMap.set(id, value + 1); + }); + return basePlaceholders?.filter((item) => { + const id = item.normalizedValue; + const value = placeholdersMap.get(id); + placeholdersMap.set(id, !value ? 0 : value - 1); + return !value; + }); + }, [basePlaceholders, lastValidPlaceholders.current]); +}; diff --git a/webapp/src/views/projects/translations/comments/Comments.tsx b/webapp/src/views/projects/translations/comments/Comments.tsx deleted file mode 100644 index ae89295d75..0000000000 --- a/webapp/src/views/projects/translations/comments/Comments.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { T } from '@tolgee/react'; -import { IconButton, styled, TextField } from '@mui/material'; -import { Send } from '@mui/icons-material'; - -import { components } from 'tg.service/apiSchema.generated'; -import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { SmoothProgress } from 'tg.component/SmoothProgress'; -import { useUser } from 'tg.globalContext/helpers'; -import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; -import { Comment } from './Comment'; -import { useComments } from './useComments'; -import { useDateCounter } from 'tg.hooks/useDateCounter'; -import { StickyDateSeparator } from 'tg.component/common/StickyDateSeparator'; - -type TranslationViewModel = components['schemas']['TranslationViewModel']; -type LanguageModel = components['schemas']['LanguageModel']; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; - flex-grow: 1; - flex-basis: 100px; - overflow: hidden; - position: relative; -`; - -const StyledScrollerWrapper = styled('div')` - flex-grow: 1; - overflow: hidden; - display: flex; - flex-direction: column; -`; - -const StyledReverseScroller = styled('div')` - margin-top: -1px; - display: flex; - flex-direction: column; - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; -`; - -const StyledLoadMore = styled('div')` - display: flex; - justify-content: center; - align-items: flex-end; - min-height: 50px; -`; - -const StyledBottomPanel = styled('div')` - display: flex; - align-items: flex-end; - border-top: 1px solid ${({ theme }) => theme.palette.divider1}; -`; - -const StyledProgressWrapper = styled('div')` - height: 0px; - position: relative; -`; - -const StyledSmoothProgress = styled(SmoothProgress)` - position: absolute; - bottom: 0px; - left: 0px; - right: 0px; -`; - -const StyledTextField = styled(TextField)` - flex-grow: 1; - padding: 12px; - align-self: center; - & *:after { - display: none; - } - & *:before { - display: none; - } - & > div { - padding: 0px; - } -`; - -type Props = { - keyId: number; - language: LanguageModel; - translation: TranslationViewModel | undefined; - onCancel: () => void; - editEnabled: boolean; -}; - -export const Comments: React.FC = ({ - keyId, - language, - translation, - onCancel, - editEnabled, -}) => { - const { satisfiesPermission } = useProjectPermissions(); - const user = useUser(); - const counter = useDateCounter(); - - const canAddComment = satisfiesPermission('translation-comments.add'); - const canEditComment = satisfiesPermission('translation-comments.edit'); - const canSetCommentState = satisfiesPermission( - 'translation-comments.set-state' - ); - - const { - commentsList, - comments, - scrollRef, - handleAddComment, - handleDelete, - handleKeyDown, - changeState, - isLoading, - isAddingComment, - inputValue, - setInputValue, - fetchMore, - } = useComments({ - keyId, - language, - translation, - onCancel, - }); - - return ( - - - - {comments.hasNextPage && ( - - - - - - )} - - {commentsList?.map((comment) => { - const canDelete = user?.id === comment.author.id || canEditComment; - const date = new Date(comment.createdAt); - return ( - - {counter.isNewDate(date) && } - - - ); - })} - - - - - - - - {canAddComment && ( - - setInputValue(e.currentTarget.value)} - onKeyDown={handleKeyDown} - variant="standard" - data-cy="translations-comments-input" - autoFocus - /> - - - - - )} - - ); -}; diff --git a/webapp/src/views/projects/translations/context/ColumnsContext.ts b/webapp/src/views/projects/translations/context/ColumnsContext.ts deleted file mode 100644 index ff39409e10..0000000000 --- a/webapp/src/views/projects/translations/context/ColumnsContext.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { createProvider } from 'tg.fixtures/createProvider'; -import { useResize, resizeColumn } from '../useResize'; - -type PassedRefType = React.RefObject; - -export const [ColumnsContext, useColumnsActions, useColumnsContext] = - createProvider(() => { - const [columnSizes, setColumnSizes] = useState(); - - const [tableRef, setTableRef] = useState({ - current: undefined, - }); - const resizersCallbacksRef = useRef<(() => void)[]>([]); - - const { width } = useResize(tableRef, columnSizes); - - const columnSizesPercent = useMemo(() => { - const columnsSum = columnSizes?.reduce((a, b) => a + b, 0) || 0; - return columnSizes?.map((size) => (size / columnsSum) * 100 + '%'); - }, [columnSizes]); - - function calcualteRealSize(prevSizes: number[] | undefined) { - const previousWidth = prevSizes?.reduce((a, b) => a + b, 0) || 1; - const newSizes = prevSizes?.map( - (w) => (w / previousWidth) * (width || 1) - ); - return newSizes; - } - - useEffect(() => { - setColumnSizes(calcualteRealSize(columnSizes)); - }, [width]); - - const actions = { - startResize(index: number) { - resizersCallbacksRef.current[index]?.(); - }, - resizeColumn(index: number, size: number) { - if (columnSizes) { - setColumnSizes(resizeColumn(columnSizes, index, size)); - } - }, - - resetColumns(sizeRatio: number[], elementRef: PassedRefType) { - setColumnSizes(calcualteRealSize(sizeRatio)); - setTableRef(elementRef); - }, - - addResizer(index: number, callback: () => void) { - resizersCallbacksRef.current[index] = callback; - }, - }; - - const context = { - totalWidth: width, - columnSizes: columnSizes || [], - columnSizesPercent: columnSizesPercent || [], - }; - - return [context, actions]; - }); diff --git a/webapp/src/views/projects/translations/context/HeaderNsContext.ts b/webapp/src/views/projects/translations/context/HeaderNsContext.ts index 0e084a513b..62a284fb3a 100644 --- a/webapp/src/views/projects/translations/context/HeaderNsContext.ts +++ b/webapp/src/views/projects/translations/context/HeaderNsContext.ts @@ -17,8 +17,9 @@ export const [HeaderNsContext, useHeaderNsActions, useHeaderNsContext] = const [topNamespace, setTopNamespace] = useState< NsBannerRecord | undefined >(undefined); + const topBarHeight = useGlobalContext((c) => c.topBarHeight); const topBannerHeight = useGlobalContext((c) => c.topBannerHeight); - const [topBarHeight, setTopBarHeight] = useState(0); + const [floatingBannerHeight, setFloatingBannerHeight] = useState(0); const nsElements = useRef>({}); @@ -50,7 +51,9 @@ export const [HeaderNsContext, useHeaderNsActions, useHeaderNsContext] = const advance = !isFirst ? 5 : 0; const top = el.getBoundingClientRect()!.top; // check exact location - return top > topBarHeight + topBannerHeight + advance; + return ( + top > floatingBannerHeight + topBannerHeight + topBarHeight + advance + ); } // take first banner that is after `start` @@ -72,7 +75,13 @@ export const [HeaderNsContext, useHeaderNsActions, useHeaderNsContext] = useEffect(() => { calculateTopNamespace(); - }, [reactList, topBarHeight, topBannerHeight, nsElements, translations]); + }, [ + reactList, + floatingBannerHeight, + topBannerHeight, + nsElements, + translations, + ]); useEffect(() => { window.addEventListener('scroll', calculateTopNamespace, { @@ -92,13 +101,14 @@ export const [HeaderNsContext, useHeaderNsActions, useHeaderNsContext] = nsElements.current[index] = el; calculateTopNamespace(); }, - setTopBarHeight(height: number) { - setTopBarHeight(height); + setFloatingBannerHeight(height: number) { + setFloatingBannerHeight(height); }, }; const contextData = { topNamespace, + floatingBannerHeight, }; return [contextData, actions]; diff --git a/webapp/src/views/projects/translations/context/TranslationsContext.ts b/webapp/src/views/projects/translations/context/TranslationsContext.ts index ad656d5c40..2b569c02c3 100644 --- a/webapp/src/views/projects/translations/context/TranslationsContext.ts +++ b/webapp/src/views/projects/translations/context/TranslationsContext.ts @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import ReactList from 'react-list'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { TolgeeFormat } from '@tginternal/editor'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; import { createProvider } from 'tg.fixtures/createProvider'; import { projectPreferencesService } from 'tg.service/ProjectPreferencesService'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; @@ -14,11 +15,11 @@ import { ChangeScreenshotNum, ChangeValue, Edit, + EditorProps, Filters, KeyElement, KeyUpdateData, RemoveTag, - ScrollToElement, SetTranslationState, UpdateTranslation, ViewMode, @@ -48,6 +49,7 @@ export const [ useTranslationsSelector, ] = createProvider((props: Props) => { const [view, setView] = useUrlSearchState('view', { defaultVal: 'LIST' }); + const [sidePanelOpen, setSidePanelOpen] = useState(false); const urlLanguages = useUrlSearchArray().languages; const requiredLanguages = urlLanguages?.length ? urlLanguages @@ -109,7 +111,7 @@ export const [ const stateService = useStateService({ translations: translationService }); const handleTranslationsReset = () => { - editService.setPosition(undefined); + editService.clearPosition(); selectionService.clear(); }; @@ -142,12 +144,21 @@ export const [ return handleTranslationsReset(); } }, - async setEdit(edit: Edit | undefined) { + async setEdit(edit: EditorProps | undefined) { if (await editService.confirmUnsavedChanges(edit)) { + setSidePanelOpen(true); return editService.setPositionAndFocus(edit); } }, - setEditForce(edit: Edit | undefined) { + async setEditValue(value: TolgeeFormat) { + setSidePanelOpen(true); + editService.setEditValue(value); + }, + async setEditValueString(value: string) { + setSidePanelOpen(true); + editService.setEditValueString(value); + }, + setEditForce(edit: EditorProps | undefined) { return editService.setPositionAndFocus(edit); }, async updateEdit(edit: Partial) { @@ -168,9 +179,6 @@ export const [ fetchMore() { return translationService.fetchNextPage(); }, - getBaseText(keyId: number) { - return translationService.getBaseText(keyId); - }, changeField(value: ChangeValue) { return editService.changeField(value); }, @@ -215,9 +223,6 @@ export const [ unregisterElement(element: KeyElement) { return viewRefs.unregisterElement(element); }, - scrollToElement(element: ScrollToElement) { - return viewRefs.scrollToElement(element); - }, focusElement(element: CellPosition) { return viewRefs.focusCell(element); }, @@ -230,6 +235,7 @@ export const [ refetchTranslations() { return translationService.refetchTranslations(); }, + setSidePanelOpen, setEventBlockers, }; @@ -238,6 +244,7 @@ export const [ ); const state = { + baseLanguage: props.baseLang!, dataReady, // changes immediately when user clicks selectedLanguages: translationService.selectedLanguages, @@ -268,6 +275,7 @@ export const [ view: view as ViewMode, elementsRef: viewRefs.elementsRef, reactList: viewRefs.reactList, + sidePanelOpen, }; return [state, actions]; diff --git a/webapp/src/views/projects/translations/context/services/useEditService.tsx b/webapp/src/views/projects/translations/context/services/useEditService.tsx index 84a94b3860..ca3b171bc5 100644 --- a/webapp/src/views/projects/translations/context/services/useEditService.tsx +++ b/webapp/src/views/projects/translations/context/services/useEditService.tsx @@ -1,5 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { T } from '@tolgee/react'; +import { useDebounce } from 'use-debounce'; +import ReactList from 'react-list'; +import { + TolgeeFormat, + getTolgeeFormat, + tolgeeFormatGenerateIcu, +} from '@tginternal/editor'; + +import { useProject } from 'tg.hooks/useProject'; +import { messageService } from 'tg.service/MessageService'; import { components } from 'tg.service/apiSchema.generated'; import { @@ -8,12 +18,42 @@ import { usePutTag, usePutTranslation, } from 'tg.service/TranslationHooks'; +import { confirmation } from 'tg.hooks/confirmation'; + import { useTranslationsService } from './useTranslationsService'; import { useRefsService } from './useRefsService'; -import { confirmation } from 'tg.hooks/confirmation'; -import { AfterCommand, ChangeValue, Direction, Edit, SetEdit } from '../types'; -import { useProject } from 'tg.hooks/useProject'; -import { messageService } from 'tg.service/MessageService'; +import { + AfterCommand, + ChangeValue, + DeletableKeyWithTranslationsModelType, + Direction, + Edit, + EditorProps, + SetEdit, +} from '../types'; +import { getPluralVariants } from '@tginternal/editor'; + +/** + * Kinda hacky way how to update react-list size cache, when editor gets open + */ +function updateListSizes(list: ReactList, currentIndex: number) { + // @ts-ignore + const cache = list.cache as Record; + // @ts-ignore + const from = list.state.from as number; + // @ts-ignore + const itemEls = list.items.children; + const elementIndex = currentIndex - from; + const previousSize = cache[currentIndex]; + const currentSize = itemEls[elementIndex]?.['offsetHeight']; + // console.log({ previousSize, currentSize }); + if (currentSize !== previousSize && typeof currentSize === 'number') { + cache[currentIndex] = currentSize; + // @ts-ignore + list.updateFrameAndClearCache(); + list.setState((state) => ({ ...state })); + } +} type KeyWithTranslationsModelType = components['schemas']['KeyWithTranslationsModel']; @@ -23,8 +63,70 @@ type Props = { viewRefs: ReturnType; }; +function generateCurrentValue( + position: EditorProps, + textValue: string | undefined, + key: DeletableKeyWithTranslationsModelType | undefined, + raw: boolean +): Edit { + const result: Edit = { + ...position, + activeVariant: position.activeVariant ?? 'other', + value: { variants: { other: textValue } }, + }; + if (position.language && key?.keyIsPlural) { + const format = getTolgeeFormat(textValue ?? '', key.keyIsPlural, raw); + const variants = getPluralVariants(position.language); + if (!position.activeVariant) { + result.activeVariant = variants[0]; + } + result.value = format; + result.value.parameter = key.keyPluralArgName ?? 'value'; + } + return result; +} + +function composeValue(position: Edit, raw: boolean) { + if (position.value) { + return tolgeeFormatGenerateIcu(position.value, raw); + } + return position.value; +} + +function serializeVariants( + variants: Record | undefined +) { + if (!variants) { + return ''; + } + return Object.entries(variants) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([_, value]) => value) + .filter((value) => Boolean(value)) + .join('<%>'); +} + export const useEditService = ({ translations, viewRefs }: Props) => { const [position, setPosition] = useState(undefined); + const currentIndex = useMemo(() => { + return translations.fixedTranslations?.findIndex( + (i) => i.keyId === position?.keyId + ); + }, [position?.keyId]); + + const key = useMemo(() => { + if (position?.keyId) { + return translations?.fixedTranslations?.find( + (t) => t.keyId === position?.keyId + ); + } + }, [position?.keyId, translations]); + + useEffect(() => { + if (viewRefs.reactList && currentIndex !== undefined) { + updateListSizes(viewRefs.reactList, currentIndex); + } + }, [position, currentIndex]); const project = useProject(); @@ -33,6 +135,38 @@ export const useEditService = ({ translations, viewRefs }: Props) => { const putTag = usePutTag(); const deleteTag = useDeleteTag(); + const originalValue = useMemo(() => { + const value = position?.language + ? key?.translations?.[position.language]?.text + : key?.keyName; + + return serializeVariants( + getTolgeeFormat( + value ?? '', + Boolean(key?.keyIsPlural), + !project.icuPlaceholders + )?.variants + ); + }, [key, position?.language, Boolean(position?.value.parameter)]); + + const [debouncedPosition] = useDebounce(position, 100, { maxWait: 100 }); + + useEffect(() => { + if (!debouncedPosition) { + return; + } + const newValue = serializeVariants(debouncedPosition.value.variants); + + const isChanged = newValue !== originalValue; + + if (isChanged !== debouncedPosition.changed) { + setPosition(() => ({ + ...debouncedPosition, + changed: isChanged, + })); + } + }, [debouncedPosition]); + useEffect(() => { // field is also focused, which breaks the scrolling // so we need to make it async @@ -43,17 +177,15 @@ export const useEditService = ({ translations, viewRefs }: Props) => { viewRefs.scrollToElement({ keyId: position.keyId, language: position.language, - options: { block: 'center', behavior: 'smooth' }, + options: { block: 'center', inline: 'center', behavior: 'smooth' }, }); } }); }, [position?.keyId, position?.language]); - const updatePosition = (newPos: Partial) => + const updatePosition = (newPos: Partial) => { setPosition((pos) => (pos ? { ...pos, ...newPos } : pos)); - - const getTranslation = (keyId: number) => - translations!.fixedTranslations!.find((t) => t.keyId === keyId)!; + }; const moveEditToDirection = (direction: Direction | undefined) => { const currentIndex = @@ -61,7 +193,7 @@ export const useEditService = ({ translations, viewRefs }: Props) => { (k) => k.keyId === position?.keyId ) || 0; if (currentIndex === -1 || !direction) { - setPosition(undefined); + clearPosition(); return; } let nextKey = undefined as KeyWithTranslationsModelType | undefined; @@ -70,12 +202,11 @@ export const useEditService = ({ translations, viewRefs }: Props) => { } else if (direction === 'UP') { nextKey = translations.fixedTranslations?.[currentIndex - 1]; } - setPosition( + setPositionAndFocus( nextKey ? { keyId: nextKey.keyId, language: position?.language, - mode: 'editor', } : undefined ); @@ -111,8 +242,8 @@ export const useEditService = ({ translations, viewRefs }: Props) => { payload: SetEdit, languagesToReturn?: string[] ) => { - const { language, value, keyId } = payload; - const { keyName, keyNamespace } = getTranslation(keyId); + const { language, value } = payload; + const { keyName, keyNamespace } = key!; const newVal = payload.value !== getEditOldValue() @@ -133,7 +264,22 @@ export const useEditService = ({ translations, viewRefs }: Props) => { return newVal; }; - const setPositionAndFocus = (pos: Edit | undefined) => { + const setPositionAndFocus = (pos: EditorProps | undefined) => { + if (!pos) { + clearPosition(); + } else { + const key = translations.fixedTranslations?.find( + (key) => key.keyId === pos.keyId + ); + + const textValue = pos.language + ? key?.translations[pos.language]?.text + : key?.keyName; + + setPosition(() => + generateCurrentValue(pos, textValue, key, !project.icuPlaceholders) + ); + } // make it async if someone is stealing focus setTimeout(() => { // focus cell when closing editor @@ -144,7 +290,6 @@ export const useEditService = ({ translations, viewRefs }: Props) => { }; viewRefs.focusCell(newPosition); } - setPosition(pos); }); }; @@ -160,7 +305,6 @@ export const useEditService = ({ translations, viewRefs }: Props) => { position.keyId !== undefined && (!newPosition || fieldIsDifferent) ) { - setPositionAndFocus({ ...position, mode: 'editor' }); confirmation({ title: , message: , @@ -185,11 +329,13 @@ export const useEditService = ({ translations, viewRefs }: Props) => { if (!position) { return; } - const { keyId, language, value } = position; + const { keyId, language } = position; + const value = composeValue(position, !project.icuPlaceholders); if (!language && !value) { // key can't be empty return messageService.error(); } + if (language) { // update translation const result = await mutateTranslation( @@ -236,13 +382,37 @@ export const useEditService = ({ translations, viewRefs }: Props) => { } }; + function clearPosition() { + setPosition(undefined); + } + + const setEditValue = (newValue: TolgeeFormat) => { + updatePosition({ + value: newValue, + }); + }; + + const setEditValueString = (value: string) => { + if (position) { + setEditValue({ + ...position.value, + variants: { + ...position.value.variants, + [position.activeVariant ?? 'other']: value, + }, + }); + } + }; + return { position, - setPosition, + clearPosition, updatePosition, setPositionAndFocus, changeField, confirmUnsavedChanges, + setEditValue, + setEditValueString, isLoading: putKey.isLoading || putTranslation.isLoading || diff --git a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx index c33cf5d797..2c2c81c3f1 100644 --- a/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx +++ b/webapp/src/views/projects/translations/context/services/useTranslationsService.tsx @@ -52,6 +52,26 @@ type Props = { baseLang: string | undefined; }; +const addBaseIfMissing = (languages: string[] | undefined, base: string) => { + if (!base) { + throw new Error('Missing base language'); + } + if (languages && languages.length > 0 && !languages.includes(base)) { + return [...languages, base]; + } + return languages; +}; + +const shaveBy = ( + largerSet: string[] | undefined, + smallerSet: string[] | undefined +) => { + if (!largerSet || !smallerSet) { + return largerSet; + } + return largerSet.filter((i) => smallerSet.includes(i)); +}; + const flattenKeys = ( data: InfiniteData ): DeletableKeyWithTranslationsModelType[] => @@ -89,7 +109,7 @@ export const useTranslationsService = (props: Props) => { useEffect(() => { const timer = setTimeout(() => { - if (query.languages !== languages) { + if (query.languages?.toString() !== languages?.toString()) { updateQuery({ languages }); } }, 500); @@ -112,6 +132,8 @@ export const useTranslationsService = (props: Props) => { const requestQuery = { ...query, + // smuggle in base lang if not present + languages: addBaseIfMissing(query.languages, props.baseLang!), ...parsedFilters, filterKeyName: props.keyName ? [props.keyName] : undefined, filterNamespace, @@ -144,13 +166,16 @@ export const useTranslationsService = (props: Props) => { onSuccess(data) { const flatKeys = flattenKeys(data); - const selectedLanguages = data.pages[0].selectedLanguages.map( - (l) => l.tag - ); + const selectedLanguages = languages?.length + ? shaveBy( + data.pages[0].selectedLanguages.map((l) => l.tag), + languages + ) + : data.pages[0].selectedLanguages.map((l) => l.tag); if (query.languages?.toString() !== selectedLanguages?.toString()) { // update language selection to the fetched one // if there are some languages which are not permitted or were deleted - _setLanguages(selectedLanguages); + _setLanguages(() => selectedLanguages); projectPreferencesService.setForProject( props.projectId, selectedLanguages @@ -246,24 +271,6 @@ export const useTranslationsService = (props: Props) => { }); }; - const getTranslations = useApiMutation({ - url: '/v2/projects/{projectId}/translations', - method: 'get', - }); - - const getBaseText = async (keyId: number) => { - const baseLanguage = props.baseLang!; - const baseTextResponse = await getTranslations.mutateAsync({ - path: { projectId: props.projectId }, - query: { filterKeyId: [keyId], languages: [baseLanguage] }, - }); - - const baseText = - baseTextResponse._embedded?.keys![0].translations[baseLanguage]?.text || - ''; - return baseText; - }; - const setFilters = (filters: FiltersType) => { refetchTranslations(() => { _setFilters(JSON.stringify(filters)); @@ -331,8 +338,9 @@ export const useTranslationsService = (props: Props) => { const totalCount = translations.data?.pages[0].page?.totalElements; const currentFetchedLangs = useMemo(() => { - const langs = translations.data?.pages[0]?.selectedLanguages.map( - (l) => l.tag + const langs = shaveBy( + translations.data?.pages[0]?.selectedLanguages.map((l) => l.tag), + languages ); if (languages) { @@ -374,7 +382,6 @@ export const useTranslationsService = (props: Props) => { setLanguages, setUrlSearch, setFilters, - getBaseText, updateTranslationKeys, updateTranslation, insertAsFirst, diff --git a/webapp/src/views/projects/translations/context/services/useWebsocketService.ts b/webapp/src/views/projects/translations/context/services/useWebsocketService.ts index 0ae05d498b..1fa18a8e73 100644 --- a/webapp/src/views/projects/translations/context/services/useWebsocketService.ts +++ b/webapp/src/views/projects/translations/context/services/useWebsocketService.ts @@ -19,7 +19,10 @@ export const useWebsocketService = ( const translationUpdates = event.data?.translations?.map((translation) => ({ keyId: translation.relations.key.entityId, language: translation.relations.language.data.tag, - value: getModifyingObject(translation.modifications), + value: { + ...getModifyingObject(translation.modifications), + id: translation.id, + }, })); if (translationUpdates) { diff --git a/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts b/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts index 56875d92bf..acdf69d31a 100644 --- a/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts +++ b/webapp/src/views/projects/translations/context/shortcuts/useTranslationsShortcuts.ts @@ -15,6 +15,7 @@ import { import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { CellPosition } from '../types'; import { getMeta } from 'tg.fixtures/isMac'; +import { isElementInput } from 'tg.fixtures/isElementInput'; export const KEY_MAP = { MOVE: ARROWS, @@ -109,7 +110,6 @@ export const useTranslationsShortcuts = () => { setEdit({ keyId: focused.keyId, language: focused.language, - mode: 'editor', }); }; } @@ -192,11 +192,7 @@ export const useTranslationsShortcuts = () => { // ignore events coming from inputs // as we don't want to influence them - if ( - activeElement.tagName === 'INPUT' && - // @ts-ignore - !['checkbox', 'radio', 'submit', 'reset'].includes(activeElement.type) - ) { + if (isElementInput(activeElement)) { return false; } diff --git a/webapp/src/views/projects/translations/context/types.ts b/webapp/src/views/projects/translations/context/types.ts index 6df89448a6..2478d9f6bc 100644 --- a/webapp/src/views/projects/translations/context/types.ts +++ b/webapp/src/views/projects/translations/context/types.ts @@ -1,5 +1,6 @@ import { components, operations } from 'tg.service/apiSchema.generated'; import { StateInType } from 'tg.constants/translationStates'; +import { TolgeeFormat } from '@tginternal/editor'; type TranslationViewModel = components['schemas']['TranslationViewModel']; type KeyWithTranslationsModel = @@ -81,13 +82,23 @@ export type SetEdit = CellPosition & { value: string; }; +export type EditorProps = CellPosition & { + mode?: EditMode; + activeVariant?: string; +}; + +export type PluralVariants = Record; + +export type EditPlural = TolgeeFormat | null; + export type Edit = CellPosition & { - value?: string; + value: TolgeeFormat; changed?: boolean; - mode: EditMode; + mode?: EditMode; + activeVariant: string; }; -export type EditMode = 'editor' | 'comments' | 'history' | 'context'; +export type EditMode = 'general' | 'advanced' | 'context' | 'comments'; export type KeyUpdateData = { keyId: number; diff --git a/webapp/src/views/projects/translations/history/History.tsx b/webapp/src/views/projects/translations/history/History.tsx deleted file mode 100644 index 7e2b0b9ce3..0000000000 --- a/webapp/src/views/projects/translations/history/History.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { T } from '@tolgee/react'; -import { FormControlLabel, styled, Switch } from '@mui/material'; - -import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { SmoothProgress } from 'tg.component/SmoothProgress'; -import { useProject } from 'tg.hooks/useProject'; -import { components } from 'tg.service/apiSchema.generated'; -import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; -import { StickyDateSeparator } from 'tg.component/common/StickyDateSeparator'; -import { useDateCounter } from 'tg.hooks/useDateCounter'; -import { HistoryItem } from './HistoryItem'; - -type TranslationViewModel = components['schemas']['TranslationViewModel']; -type TranslationHistoryModel = components['schemas']['TranslationHistoryModel']; -type LanguageModel = components['schemas']['LanguageModel']; - -const StyledContainer = styled('div')` - display: flex; - flex-direction: column; - flex-grow: 1; - flex-basis: 100px; - position: relative; - contain: size; - overflow: hidden; -`; - -const StyledDifferenceToggle = styled(FormControlLabel)` - position: absolute; - right: 24px; - top: 2px; - z-index: 2; - display: flex; - align-items: start; - & > span { - font-size: 14px; - } -`; - -const StyledScroller = styled('div')` - margin-top: -1px; - display: flex; - flex-direction: column; - overflow-y: auto; - overflow-x: hidden; - overscroll-behavior: contain; -`; - -const StyledProgressWrapper = styled('div')` - position: absolute; - bottom: 0px; - left: 0px; - right: 0px; -`; - -const StyledLoadMore = styled('div')` - display: flex; - justify-content: center; - align-items: flex-end; - min-height: 50px; -`; - -type Props = { - language: LanguageModel; - translation: TranslationViewModel | undefined; -}; - -export const History: React.FC = ({ translation, language }) => { - const scrollerRef = useRef(null); - const project = useProject(); - const counter = useDateCounter(); - - const path = { - projectId: project.id, - translationId: translation?.id as number, - }; - const query = { - size: 20, - }; - - const fetchMore = () => { - const previousHeight = Number(scrollerRef.current?.scrollHeight); - history.fetchNextPage().then(() => { - const newHeight = Number(scrollerRef.current?.scrollHeight); - scrollerRef.current?.scrollTo({ - // persist scrolling position - top: newHeight - previousHeight, - }); - }); - }; - - const history = useApiInfiniteQuery({ - url: '/v2/projects/{projectId}/translations/{translationId}/history', - method: 'get', - path, - query, - options: { - enabled: Boolean(translation?.id), - getNextPageParam: (lastPage) => { - if ( - lastPage.page && - lastPage.page.number! < lastPage.page.totalPages! - 1 - ) { - return { - path, - query: { - ...query, - page: lastPage.page!.number! + 1, - }, - }; - } else { - return null; - } - }, - }, - }); - - const historyItems: TranslationHistoryModel[] = []; - - history.data?.pages.forEach((page) => - page._embedded?.revisions?.forEach((item) => historyItems.push(item)) - ); - - historyItems.reverse(); - - useEffect(() => { - scrollerRef.current?.scrollTo({ top: scrollerRef.current.scrollHeight }); - }, [history.data?.pages?.[0].page?.totalElements]); - - const [showdifferences, setShowDifferences] = useState(true); - const toggleDifferences = () => setShowDifferences((val) => !val); - - return ( - - } - labelPlacement="start" - control={ - - } - /> - - {history.hasNextPage && ( - - - - - - )} - {historyItems?.map((entry) => { - const date = new Date(entry.timestamp); - return ( - - {counter.isNewDate(date) && } - - - ); - })} - - - - - - ); -}; diff --git a/webapp/src/views/projects/translations/translationVisual/PluralEditor.tsx b/webapp/src/views/projects/translations/translationVisual/PluralEditor.tsx new file mode 100644 index 0000000000..56478a06aa --- /dev/null +++ b/webapp/src/views/projects/translations/translationVisual/PluralEditor.tsx @@ -0,0 +1,73 @@ +import { TranslationPlurals } from './TranslationPlurals'; +import { EditorWrapper } from 'tg.component/editor/EditorWrapper'; +import { Editor, EditorProps } from 'tg.component/editor/Editor'; +import { TolgeeFormat } from '@tginternal/editor'; +import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; +import { RefObject } from 'react'; +import { EditorView } from 'codemirror'; +import { useProject } from 'tg.hooks/useProject'; + +type Props = { + locale: string; + value: TolgeeFormat; + onChange?: (value: TolgeeFormat) => void; + activeVariant?: string; + onActiveVariantChange?: (variant: string) => void; + editorProps?: Partial; + autofocus?: boolean; + activeEditorRef?: RefObject; + mode: 'placeholders' | 'syntax'; +}; + +export const PluralEditor = ({ + locale, + value, + onChange, + activeVariant, + onActiveVariantChange, + autofocus, + activeEditorRef, + editorProps, + mode, +}: Props) => { + function handleChange(text: string, variant: string) { + onChange?.({ ...value, variants: { ...value.variants, [variant]: text } }); + } + + const project = useProject(); + + const editorMode = project.icuPlaceholders ? mode : 'plain'; + + return ( + { + const variantOrOther = variant || 'other'; + return ( + + handleChange(value, variantOrOther)} + onFocus={() => onActiveVariantChange?.(variantOrOther)} + direction={getLanguageDirection(locale)} + autofocus={variantOrOther === activeVariant ? autofocus : false} + minHeight={value.parameter ? 'unset' : '100px'} + locale={locale} + editorRef={ + variantOrOther === activeVariant ? activeEditorRef : undefined + } + examplePluralNum={exampleValue} + nested={Boolean(variant)} + {...editorProps} + /> + + ); + }} + /> + ); +}; diff --git a/webapp/src/views/projects/translations/translationVisual/TranslationPlurals.tsx b/webapp/src/views/projects/translations/translationVisual/TranslationPlurals.tsx new file mode 100644 index 0000000000..0fce5348ed --- /dev/null +++ b/webapp/src/views/projects/translations/translationVisual/TranslationPlurals.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { styled } from '@mui/material'; +import React from 'react'; +import { + TolgeeFormat, + getPluralVariants, + getVariantExample, +} from '@tginternal/editor'; + +const StyledContainer = styled('div')` + display: grid; + gap: 2px; +`; + +const StyledContainerSimple = styled('div')` + padding-top: 4px; +`; + +const StyledVariants = styled('div')` + display: grid; + grid-template-columns: 56px 1fr; + gap: 8px; +`; + +const StyledParameter = styled('div')` + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: 14px; +`; + +const StyledVariantLabel = styled('div')` + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: 24px; + border: 1px solid ${({ theme }) => theme.palette.placeholders.variant.border}; + background-color: ${({ theme }) => + theme.palette.placeholders.variant.background}; + color: ${({ theme }) => theme.palette.placeholders.variant.text}; + border-radius: 12px; + padding: 0px 9px; + font-size: 14px; + user-select: none; + margin: 0px 1px; + text-transform: capitalize; + white-space: nowrap; + & > * { + margin-top: -1px; + } +`; + +const StyledVariantContent = styled('div')` + display: block; +`; + +type RenderProps = { + content: string; + variant: string | undefined; + locale: string; + exampleValue?: number; +}; + +type Props = { + locale: string; + value: TolgeeFormat; + render: (props: RenderProps) => React.ReactNode; + showEmpty?: boolean; + activeVariant?: string; + variantPaddingTop?: number | string; +}; + +export const TranslationPlurals = ({ + locale, + render, + value, + showEmpty, + activeVariant, + variantPaddingTop, +}: Props) => { + const variants = useMemo(() => { + const existing = new Set(Object.keys(value.variants)); + const required = getPluralVariants(locale); + required.forEach((val) => existing.delete(val)); + const result = Array.from(existing).map((value) => { + return [value, getVariantExample(locale, value)] as const; + }); + required.forEach((value) => { + result.push([value, getVariantExample(locale, value)]); + }); + return result; + }, [locale]); + + if (value.parameter) { + return ( + + + {value.parameter} + + + {variants + .filter(([variant]) => showEmpty || value.variants[variant]) + .map(([variant, exampleValue]) => { + const inactive = activeVariant && activeVariant !== variant; + const opacity = inactive ? 0.5 : 1; + return ( + + +
{variant}
+
+ + {render({ + variant: variant, + content: value.variants[variant] || '', + exampleValue: exampleValue, + locale, + })} + +
+ ); + })} +
+
+ ); + } + return ( + + {render({ + content: value.variants['other'] ?? '', + locale, + variant: undefined, + })} + + ); +}; diff --git a/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx b/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx new file mode 100644 index 0000000000..9a0cede699 --- /dev/null +++ b/webapp/src/views/projects/translations/translationVisual/TranslationVisual.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import { getTolgeeFormat } from '@tginternal/editor'; +import { LimitedHeightText } from 'tg.component/LimitedHeightText'; + +import { TranslationPlurals } from './TranslationPlurals'; +import { TranslationWithPlaceholders } from './TranslationWithPlaceholders'; +import { T } from '@tolgee/react'; +import { styled } from '@mui/material'; +import { DirectionLocaleWrapper } from '../DirectionLocaleWrapper'; +import { useProject } from 'tg.hooks/useProject'; + +const StyledDisabled = styled(DirectionLocaleWrapper)` + color: ${({ theme }) => theme.palette.text.disabled}; + cursor: default; +`; + +type Props = { + maxLines?: number; + text: string | undefined; + locale: string; + width?: number | string; + disabled?: boolean; + isPlural: boolean; +}; + +export const TranslationVisual = ({ + maxLines, + text, + locale, + width, + disabled, + isPlural, +}: Props) => { + const project = useProject(); + const value = useMemo(() => { + return getTolgeeFormat(text || '', isPlural, !project.icuPlaceholders); + }, [text, isPlural]); + + if (disabled) { + return ( + + + + ); + } + + return ( + ( + + + + )} + /> + ); +}; diff --git a/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx b/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx new file mode 100644 index 0000000000..b64b8ffb01 --- /dev/null +++ b/webapp/src/views/projects/translations/translationVisual/TranslationWithPlaceholders.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { generatePlaceholdersStyle, getPlaceholders } from '@tginternal/editor'; +import { styled, useTheme } from '@mui/material'; +import { getLanguageDirection } from 'tg.fixtures/getLanguageDirection'; +import { placeholderToElement } from './placeholderToElement'; +import { useProject } from 'tg.hooks/useProject'; + +const StyledWrapper = styled('div')` + white-space: pre-wrap; +`; + +type Props = { + content: string; + pluralExampleValue?: number | undefined; + locale: string; + nested: boolean; +}; + +export const TranslationWithPlaceholders = ({ + content, + pluralExampleValue, + locale, + nested, +}: Props) => { + const project = useProject(); + const theme = useTheme(); + const direction = getLanguageDirection(locale); + const placeholders = useMemo(() => { + if (!project.icuPlaceholders) { + return []; + } + return getPlaceholders(content, nested) || []; + }, [content, nested]); + + const StyledPlaceholdersWrapper = useMemo(() => { + return generatePlaceholdersStyle({ + styled, + colors: theme.palette.placeholders, + component: StyledWrapper, + }); + }, [theme.palette.placeholders]); + + const chunks: React.ReactNode[] = []; + let index = 0; + for (const placeholder of placeholders) { + if (placeholder.position.start !== index) { + chunks.push(content.substring(index, placeholder.position.start)); + } + index = placeholder.position.end; + chunks.push( + placeholderToElement({ placeholder, pluralExampleValue, key: index }) + ); + } + + if (index < content.length) { + chunks.push(content.substring(index)); + } + + return ( + + {chunks} + + ); +}; diff --git a/webapp/src/views/projects/translations/translationVisual/placeholderToElement.tsx b/webapp/src/views/projects/translations/translationVisual/placeholderToElement.tsx new file mode 100644 index 0000000000..d889b4dab1 --- /dev/null +++ b/webapp/src/views/projects/translations/translationVisual/placeholderToElement.tsx @@ -0,0 +1,44 @@ +import { Placeholder } from '@tginternal/editor'; + +type Props = { + placeholder: Placeholder; + pluralExampleValue?: number | undefined; + key: any; + props?: React.HtmlHTMLAttributes; +}; + +export const placeholderToElement = ({ + placeholder, + pluralExampleValue, + key, + props, +}: Props) => { + const className = `placeholder-widget placeholder-${placeholder.type}`; + + switch (placeholder.type) { + case 'hash': + return ( +
+
{`${placeholder.name}${pluralExampleValue ?? ''}`}
+
+ ); + case 'variable': + return ( +
+
{placeholder.name}
+
+ ); + case 'tagOpen': + return ( +
+
{placeholder.name}
+
+ ); + case 'tagClose': + return ( +
+
{placeholder.name}
+
+ ); + } +}; diff --git a/webapp/src/views/projects/translations/translationVisual/useTranslationPlurals.tsx b/webapp/src/views/projects/translations/translationVisual/useTranslationPlurals.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webapp/src/views/projects/translations/useBaseTranslation.ts b/webapp/src/views/projects/translations/useBaseTranslation.ts new file mode 100644 index 0000000000..2904f6ec8c --- /dev/null +++ b/webapp/src/views/projects/translations/useBaseTranslation.ts @@ -0,0 +1,23 @@ +import { getTolgeeFormat } from '@tginternal/editor'; +import { useMemo } from 'react'; +import { useProject } from 'tg.hooks/useProject'; + +export const useBaseTranslation = ( + activeVariant: string | undefined, + baseTranslation: string | undefined, + isPlural: boolean +) => { + const project = useProject(); + return useMemo(() => { + if (activeVariant) { + const variants = getTolgeeFormat( + baseTranslation || '', + isPlural, + !project.icuPlaceholders + )?.variants; + return variants?.[activeVariant] ?? variants?.['other']; + } else { + return baseTranslation; + } + }, [baseTranslation, activeVariant]); +}; diff --git a/webapp/src/views/projects/translations/useColumns.ts b/webapp/src/views/projects/translations/useColumns.ts new file mode 100644 index 0000000000..d0267eb061 --- /dev/null +++ b/webapp/src/views/projects/translations/useColumns.ts @@ -0,0 +1,83 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { resizeColumn, useResize } from './useResize'; + +type PassedRefType = React.RefObject; + +type Props = { + tableRef: PassedRefType; + initialRatios: number[]; + minSize?: number; + deps?: React.DependencyList; +}; + +export const useColumns = ({ + tableRef, + initialRatios, + minSize, + deps = [], +}: Props) => { + const [columnSizes, setColumnSizes] = useState(initialRatios); + const resizersCallbacksRef = useRef<(() => void)[]>([]); + + const { width } = useResize(tableRef, deps); + + const columnSizesPercent = useMemo(() => { + const columnsSum = columnSizes?.reduce((a, b) => a + b, 0) || 0; + + if (minSize) { + return columnSizes?.map((size) => size + 'px'); + } + + return columnSizes?.map((size) => (size / columnsSum) * 100 + '%'); + }, [columnSizes, width]); + + function calcualteRealSize(prevSizes: number[]) { + const previousWidth = prevSizes?.reduce((a, b) => a + b, 0) || 1; + const newSizes = prevSizes?.map((w) => { + const newSize = (w / previousWidth) * (width || 1); + if (minSize && newSize < minSize) { + return minSize; + } + return newSize; + }); + return newSizes; + } + + useEffect(() => { + setColumnSizes(calcualteRealSize(initialRatios)); + }, [initialRatios.length]); + + useEffect(() => { + setColumnSizes(calcualteRealSize(columnSizes)); + }, [width, minSize]); + + const actions = { + startResize(index: number) { + resizersCallbacksRef.current[index]?.(); + }, + resizeColumn(index: number, size: number) { + if (columnSizes) { + setColumnSizes( + resizeColumn({ + allSizes: columnSizes, + index, + newSize: size, + minSize, + originalRatios: initialRatios, + }) + ); + } + }, + addResizer(index: number, callback: () => void) { + resizersCallbacksRef.current[index] = callback; + }, + }; + + const context = { + totalWidth: width, + columnSizes: columnSizes || [], + columnSizesPercent: columnSizesPercent || [], + }; + + return { ...context, ...actions }; +}; diff --git a/webapp/src/views/projects/translations/useEditableRow.ts b/webapp/src/views/projects/translations/useEditableRow.ts deleted file mode 100644 index 201f5ac08e..0000000000 --- a/webapp/src/views/projects/translations/useEditableRow.ts +++ /dev/null @@ -1,129 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -import { - useTranslationsSelector, - useTranslationsActions, -} from './context/TranslationsContext'; -import { AfterCommand, EditMode } from './context/types'; -import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; - -type Props = { - keyId: number; - keyName: string; - language: string | undefined; - defaultVal?: string; - onSaveSuccess?: (val: string) => void; - cellRef: React.RefObject; -}; - -export const useEditableRow = ({ - keyId, - defaultVal, - language, - onSaveSuccess, - cellRef, -}: Props) => { - const { - updateEdit, - registerElement, - unregisterElement, - setEdit, - changeField, - getBaseText, - setEditForce, - } = useTranslationsActions(); - - const cursor = useTranslationsSelector((v) => { - return v.cursor?.keyId === keyId ? v.cursor : undefined; - }); - - const { satisfiesLanguageAccess } = useProjectPermissions(); - const baseLanguage = useTranslationsSelector((c) => - c.languages?.find((l) => l.base) - ); - - const isEditingRow = Boolean(cursor?.keyId === keyId); - const isEditing = Boolean(isEditingRow && cursor?.language === language); - - const value = (isEditing && cursor?.value) || ''; - - const originalValue = (isEditing && defaultVal) || ''; - - const setValue = (val: string) => updateEdit({ value: val }); - - useEffect(() => { - registerElement({ keyId, language, ref: cellRef.current! }); - return () => { - unregisterElement({ keyId, language, ref: cellRef.current! }); - }; - }, [cellRef.current, keyId, language]); - - useEffect(() => { - if (isEditing) { - setValue(cursor?.value || originalValue); - } - }, [isEditing, originalValue]); - - const handleOpen = (mode: EditMode) => { - setEdit({ - keyId, - language, - mode, - }); - }; - - const handleSave = (after?: AfterCommand) => { - changeField({ - after, - onSuccess: () => onSaveSuccess?.(value), - }); - }; - - const handleInsertBase = async (baseText: string | undefined) => { - if (baseText) { - setValue(baseText); - } else if (satisfiesLanguageAccess('translations.view', baseLanguage?.id)) { - setValue(await getBaseText(keyId)); - } - }; - - const handleClose = (force = false) => { - if (force) { - setEditForce(undefined); - } else { - setEdit(undefined); - } - }; - - const handleModeChange = (mode: EditMode) => { - updateEdit({ mode }); - }; - - useEffect(() => { - const isChanged = originalValue !== value; - // let context know, that something has changed - if (isEditing && Boolean(cursor?.changed) !== isChanged) { - updateEdit({ changed: isChanged }); - } - }, [originalValue, cursor?.changed, value]); - - const valueRef = useRef(isEditing ? value : undefined); - - useEffect(() => { - valueRef.current = isEditing ? value : undefined; - }, [value, isEditing]); - - return { - handleOpen, - handleClose, - handleSave, - handleInsertBase, - handleModeChange, - value, - setValue, - editVal: isEditing ? cursor : undefined, - isEditing, - isEditingRow, - autofocus: true, - }; -}; diff --git a/webapp/src/views/projects/translations/useKeyCell.ts b/webapp/src/views/projects/translations/useKeyCell.ts new file mode 100644 index 0000000000..f7f2b4a1a7 --- /dev/null +++ b/webapp/src/views/projects/translations/useKeyCell.ts @@ -0,0 +1,71 @@ +import React, { useEffect } from 'react'; + +import { + useTranslationsSelector, + useTranslationsActions, +} from './context/TranslationsContext'; +import { + DeletableKeyWithTranslationsModelType, + EditMode, +} from './context/types'; + +type Props = { + keyData: DeletableKeyWithTranslationsModelType; + cellRef: React.RefObject; +}; + +export const useKeyCell = ({ keyData, cellRef }: Props) => { + const { + setEditValue, + setEditValueString, + registerElement, + unregisterElement, + setEdit, + setEditForce, + } = useTranslationsActions(); + + const keyId = keyData.keyId; + + const cursor = useTranslationsSelector((v) => { + return v.cursor?.keyId === keyId ? v.cursor : undefined; + }); + + const isEditingRow = Boolean(cursor?.keyId === keyId); + const isEditing = Boolean(isEditingRow && cursor?.language === undefined); + + useEffect(() => { + registerElement({ keyId, language: undefined, ref: cellRef.current! }); + return () => { + unregisterElement({ keyId, language: undefined, ref: cellRef.current! }); + }; + }, [cellRef.current, keyId]); + + const handleOpen = (mode?: EditMode) => { + setEdit({ + keyId, + language: undefined, + mode, + }); + }; + + const handleClose = (force = false) => { + if (force) { + setEditForce(undefined); + } else { + setEdit(undefined); + } + }; + + return { + keyId, + handleOpen, + handleClose, + setEditValue, + setEditValueString, + editVal: isEditing ? cursor : undefined, + isEditing, + isEditingRow, + autofocus: true, + keyData, + }; +}; diff --git a/webapp/src/views/projects/translations/useResize.ts b/webapp/src/views/projects/translations/useResize.ts index de7d172919..5d63af88f5 100644 --- a/webapp/src/views/projects/translations/useResize.ts +++ b/webapp/src/views/projects/translations/useResize.ts @@ -2,7 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; type PassedRefType = React.RefObject; -export const useResize = (tableRef: PassedRefType, dependency: any) => { +export const useResize = ( + tableRef: PassedRefType, + deps: React.DependencyList = [] +) => { const [width, setWidth] = useState(); const handleResize = useCallback(() => { @@ -14,7 +17,7 @@ export const useResize = (tableRef: PassedRefType, dependency: any) => { useEffect(() => { handleResize(); - }, [dependency]); + }, deps); useEffect(() => { window.addEventListener('resize', handleResize); @@ -28,29 +31,66 @@ export const useResize = (tableRef: PassedRefType, dependency: any) => { return { width: width || 0 }; }; -export const resizeColumn = ( - allSizes: number[], - index: number, - newSize: number, - minSizeMult = 0.5 -) => { +const minSizeMult = 0.5; + +type Props = { + allSizes: number[]; + index: number; + newSize: number; + originalRatios: number[]; + minSize?: number; +}; + +export const resizeColumn = ({ + allSizes, + index, + newSize, + originalRatios, + minSize, +}: Props) => { const oldColumnSize = allSizes[index]; let newColumnSize = newSize; const totalSize = allSizes.reduce((a, b) => a + b, 0); - const minSize = (totalSize / allSizes.length) * minSizeMult; + let minSizeCalculated: number; + const originalSum = originalRatios.reduce((prev, curr) => prev + curr, 0); + const originalSizes = originalRatios.map( + (ratio) => (totalSize / originalSum) * ratio + ); + if (minSize === undefined) { + minSizeCalculated = originalSizes[index] * minSizeMult; + } else { + minSizeCalculated = minSize; + } + const margins = allSizes.map( + (size, i) => size - originalSizes[i] * minSizeMult + ); + const originalRatiosAfter = originalRatios.slice(index + 1); const columnsAfter = allSizes.slice(index + 1); - const marginsAfter = columnsAfter.map((w) => w - minSize); + const marginsAfter = margins.slice(index + 1); + const originalRatiosSum = originalRatiosAfter.reduce( + (prev, curr) => prev + curr + ); + const maxIncrease = marginsAfter.reduce((a, b) => a + b, 0); - if (newColumnSize < minSize) { - newColumnSize = minSize; + + if (newColumnSize < minSizeCalculated) { + newColumnSize = minSizeCalculated; } else if (newColumnSize - oldColumnSize > maxIncrease) { newColumnSize = oldColumnSize + maxIncrease; } const columnsBefore = allSizes.slice(0, index); + const difference = newColumnSize - oldColumnSize; + + let newAfterSizes: number[]; + if (!minSize) { + newAfterSizes = columnsAfter.map((size, i) => { + const portion = originalRatiosAfter[i] / originalRatiosSum; + return size - portion * difference; + }); + } else { + newAfterSizes = columnsAfter; + } - const newAfterSizes = marginsAfter.map((w) => { - const portion = maxIncrease ? w / maxIncrease : 1 / marginsAfter.length; - return minSize + (w - portion * (newColumnSize - oldColumnSize)); - }); - return [...columnsBefore, newColumnSize, ...newAfterSizes]; + const result = [...columnsBefore, newColumnSize, ...newAfterSizes]; + return result; }; diff --git a/webapp/src/views/projects/translations/useTranslationCell.ts b/webapp/src/views/projects/translations/useTranslationCell.ts new file mode 100644 index 0000000000..4095ff49d5 --- /dev/null +++ b/webapp/src/views/projects/translations/useTranslationCell.ts @@ -0,0 +1,175 @@ +import React, { useEffect } from 'react'; +import { getTolgeeFormat } from '@tginternal/editor'; + +import { TRANSLATION_STATES } from 'tg.constants/translationStates'; +import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; +import { components } from 'tg.service/apiSchema.generated'; + +import { + useTranslationsSelector, + useTranslationsActions, +} from './context/TranslationsContext'; +import { + AfterCommand, + DeletableKeyWithTranslationsModelType, + EditMode, +} from './context/types'; +import { useProject } from 'tg.hooks/useProject'; + +type LanguageModel = components['schemas']['LanguageModel']; + +type Props = { + keyData: DeletableKeyWithTranslationsModelType; + language: LanguageModel; + onSaveSuccess?: (val: string) => void; + cellRef: React.RefObject; +}; + +export const useTranslationCell = ({ + keyData, + language, + onSaveSuccess, + cellRef, +}: Props) => { + const project = useProject(); + const { + setEditValue, + setEditValueString, + registerElement, + unregisterElement, + setEdit, + changeField, + setEditForce, + setTranslationState, + updateEdit, + } = useTranslationsActions(); + + const { satisfiesLanguageAccess } = useProjectPermissions(); + + const keyId = keyData.keyId; + const langTag = language.tag; + + const cursor = useTranslationsSelector((v) => { + return v.cursor?.keyId === keyId && v.cursor.language === language.tag + ? v.cursor + : undefined; + }); + + const baseLanguage = useTranslationsSelector((c) => + c.languages?.find((l) => l.base) + ); + + const isEditingRow = Boolean(cursor?.keyId === keyId); + const isEditing = Boolean(isEditingRow && cursor?.language === langTag); + + const value = + (isEditing && cursor?.value.variants[cursor.activeVariant ?? 'other']) || + ''; + + useEffect(() => { + registerElement({ keyId, language: langTag, ref: cellRef.current! }); + return () => { + unregisterElement({ keyId, language: langTag, ref: cellRef.current! }); + }; + }, [cellRef.current, keyId, langTag]); + + const handleOpen = (mode?: EditMode) => { + setEdit({ + keyId, + language: langTag, + mode, + }); + }; + + const handleSave = (after?: AfterCommand) => { + changeField({ + after, + onSuccess: () => onSaveSuccess?.(value), + }); + }; + + const handleInsertBase = () => { + if (!baseLanguage?.tag) { + return; + } + + const baseText = keyData.translations[baseLanguage.tag].text; + + let baseVariant: string | undefined; + if (cursor?.activeVariant) { + const variants = getTolgeeFormat( + baseText || '', + keyData.keyIsPlural, + !project.icuPlaceholders + )?.variants; + baseVariant = variants?.[cursor.activeVariant] ?? variants?.['other']; + } else { + baseVariant = baseText; + } + + if (baseVariant) { + setEditValueString(baseVariant); + } + }; + + const handleClose = (force = false) => { + if (force) { + setEditForce(undefined); + } else { + setEdit(undefined); + } + }; + + const translation = langTag ? keyData?.translations[langTag] : undefined; + + const setState = () => { + if (!translation) { + return; + } + const nextState = TRANSLATION_STATES[translation.state]?.next; + if (nextState) { + setTranslationState({ + state: nextState, + keyId, + translationId: translation!.id, + language: langTag!, + }); + } + }; + + function setVariant(activeVariant: string | undefined) { + updateEdit({ activeVariant }); + } + + const canChangeState = satisfiesLanguageAccess( + 'translations.state-edit', + language.id + ); + + const disabled = translation?.state === 'DISABLED'; + const editEnabled = + satisfiesLanguageAccess('translations.edit', language.id) && !disabled; + + return { + keyId, + language, + handleOpen, + handleClose, + handleSave, + handleInsertBase, + setEditValue, + setEditValueString, + setState, + setVariant, + value, + editVal: isEditing ? cursor : undefined, + isEditing, + isEditingRow, + autofocus: true, + keyData, + canChangeState, + editEnabled, + translation, + disabled, + }; +};