diff --git a/.gitignore b/.gitignore index 8429186..09cf028 100644 --- a/.gitignore +++ b/.gitignore @@ -309,6 +309,7 @@ dcs-docs-preview.zip docfx.zip DisCatSharp.Docs/_site/ _site +dcs-artifacts/ #Ignore thumbnails created by Windows Thumbs.db diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/index.md b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/index.md new file mode 100644 index 0000000..7d3a18a --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/index.md @@ -0,0 +1,11 @@ +--- +uid: api_discatsharp_extensions_translations_applicationcommands_index +title: DisCatSharp Translations ApplicationCommands Extension API Reference +author: DisCatSharp Team +--- + +# API Reference + +Welcome to the DisCatSharp Translations ApplicationCommands Extension API reference. + +To begin, select a namespace, then a class, from the table of contents on the left. diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/toc.yml b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/toc.yml new file mode 100644 index 0000000..9c97191 --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.ApplicationCommands/toc.yml @@ -0,0 +1,10 @@ +### YamlMime:TableOfContent +items: +- uid: DisCatSharp.Extensions.Translations.ApplicationCommands + name: DisCatSharp.Extensions.Translations.ApplicationCommands + type: Namespace + items: + - uid: DisCatSharp.Extensions.Translations.ApplicationCommands.ExtensionMethods + name: ExtensionMethods + type: Class +memberLayout: SeparatePages diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/index.md b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/index.md new file mode 100644 index 0000000..2f039af --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/index.md @@ -0,0 +1,11 @@ +--- +uid: api_discatsharp_extensions_translations_commandsnext_index +title: DisCatSharp Translations CommandsNext Extension API Reference +author: DisCatSharp Team +--- + +# API Reference + +Welcome to the DisCatSharp Translations CommandsNext Extension API reference. + +To begin, select a namespace, then a class, from the table of contents on the left. diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/toc.yml b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/toc.yml new file mode 100644 index 0000000..a150e35 --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.CommandsNext/toc.yml @@ -0,0 +1,10 @@ +### YamlMime:TableOfContent +items: +- uid: DisCatSharp.Extensions.Translations.CommandsNext + name: DisCatSharp.Extensions.Translations.CommandsNext + type: Namespace + items: + - uid: DisCatSharp.Extensions.Translations.CommandsNext.ExtensionMethods + name: ExtensionMethods + type: Class +memberLayout: SeparatePages diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/index.md b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/index.md new file mode 100644 index 0000000..282e9fa --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/index.md @@ -0,0 +1,11 @@ +--- +uid: api_discatsharp_extensions_translations_manager_index +title: DisCatSharp Translations Manager Extension API Reference +author: DisCatSharp Team +--- + +# API Reference + +Welcome to the DisCatSharp Translations Manager Extension API reference. + +To begin, select a namespace, then a class, from the table of contents on the left. diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/toc.yml b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/toc.yml new file mode 100644 index 0000000..bfbe9b5 --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations.Manager/toc.yml @@ -0,0 +1,13 @@ +### YamlMime:TableOfContent +items: +- uid: DisCatSharp.Extensions.Translations.Manager + name: DisCatSharp.Extensions.Translations.Manager + type: Namespace + items: + - uid: DisCatSharp.Extensions.Translations.Manager.TranslationManager + name: TranslationManager + type: Class + - uid: DisCatSharp.Extensions.Translations.Manager.TranslationsManagerConfiguration + name: TranslationsManagerConfiguration + type: Class +memberLayout: SeparatePages diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/index.md b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/index.md new file mode 100644 index 0000000..1b1712a --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/index.md @@ -0,0 +1,11 @@ +--- +uid: api_discatsharp_extensions_translations_index +title: DisCatSharp Translations Extension API Reference +author: DisCatSharp Team +--- + +# API Reference + +Welcome to the DisCatSharp Translations Extension API reference. + +To begin, select a namespace, then a class, from the table of contents on the left. diff --git a/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/toc.yml b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/toc.yml new file mode 100644 index 0000000..ab0a8c7 --- /dev/null +++ b/DisCatSharp.Extensions.Docs/api/DisCatSharp.Extensions.Translations/toc.yml @@ -0,0 +1,19 @@ +### YamlMime:TableOfContent +items: +- uid: DisCatSharp.Extensions.Translations + name: DisCatSharp.Extensions.Translations + type: Namespace + items: + - uid: DisCatSharp.Extensions.Translations.ExtensionMethods + name: ExtensionMethods + type: Class + - uid: DisCatSharp.Extensions.Translations.TranslationEngine + name: TranslationEngine + type: Class + - uid: DisCatSharp.Extensions.Translations.TranslationsConfiguration + name: TranslationsConfiguration + type: Class + - uid: DisCatSharp.Extensions.Translations.TranslationsExtension + name: TranslationsExtension + type: Class +memberLayout: SeparatePages diff --git a/DisCatSharp.Extensions.Docs/api/index.md b/DisCatSharp.Extensions.Docs/api/index.md index f68f68c..a559d32 100644 --- a/DisCatSharp.Extensions.Docs/api/index.md +++ b/DisCatSharp.Extensions.Docs/api/index.md @@ -10,3 +10,7 @@ Welcome to the DisCatSharp Extensions Global API reference. - [DisCatSharp.Extensions.TwoFactorCommands](xref:api_discatsharp_extensions_twofactorcommands_index) - [DisCatSharp.Extensions.OAuth2Web](xref:api_discatsharp_extensions_oauth2web_index) - [DisCatSharp.Extensions.SimpleMusicCommands](xref:api_discatsharp_extensions_simplemusiccommands_index) +- [DisCatSharp.Extensions.Translations](xref:api_discatsharp_extensions_translations_index) +- [DisCatSharp.Extensions.Translations.ApplicationCommands](xref:api_discatsharp_extensions_translations_applicationcommands_index) +- [DisCatSharp.Extensions.Translations.CommandsNext](xref:api_discatsharp_extensions_translations_commandsnext_index) +- [DisCatSharp.Extensions.Translations.Manager](xref:api_discatsharp_extensions_translations_manager_index) diff --git a/DisCatSharp.Extensions.Docs/docfx.json b/DisCatSharp.Extensions.Docs/docfx.json index b955147..9b54c5d 100644 --- a/DisCatSharp.Extensions.Docs/docfx.json +++ b/DisCatSharp.Extensions.Docs/docfx.json @@ -47,6 +47,70 @@ "namespaceLayout": "flattened", "enumSortOrder": "declaringOrder", "includeExplicitInterfaceImplementations": true + }, + { + "src": [ + { + "src": "../DisCatSharp.Extensions.Translations/", + "files": ["**.csproj"], + "exclude": ["**/obj/**", "**/bin/**"] + } + ], + "dest": "api/DisCatSharp.Extensions.Translations", + "filter": "filter_config.yml", + "disableDefaultFilter": false, + "memberLayout": "separatePages", + "namespaceLayout": "flattened", + "enumSortOrder": "declaringOrder", + "includeExplicitInterfaceImplementations": true + }, + { + "src": [ + { + "src": "../DisCatSharp.Extensions.Translations.ApplicationCommands/", + "files": ["**.csproj"], + "exclude": ["**/obj/**", "**/bin/**"] + } + ], + "dest": "api/DisCatSharp.Extensions.Translations.ApplicationCommands", + "filter": "filter_config.yml", + "disableDefaultFilter": false, + "memberLayout": "separatePages", + "namespaceLayout": "flattened", + "enumSortOrder": "declaringOrder", + "includeExplicitInterfaceImplementations": true + }, + { + "src": [ + { + "src": "../DisCatSharp.Extensions.Translations.CommandsNext/", + "files": ["**.csproj"], + "exclude": ["**/obj/**", "**/bin/**"] + } + ], + "dest": "api/DisCatSharp.Extensions.Translations.CommandsNext", + "filter": "filter_config.yml", + "disableDefaultFilter": false, + "memberLayout": "separatePages", + "namespaceLayout": "flattened", + "enumSortOrder": "declaringOrder", + "includeExplicitInterfaceImplementations": true + }, + { + "src": [ + { + "src": "../DisCatSharp.Extensions.Translations.Manager/", + "files": ["**.csproj"], + "exclude": ["**/obj/**", "**/bin/**"] + } + ], + "dest": "api/DisCatSharp.Extensions.Translations.Manager", + "filter": "filter_config.yml", + "disableDefaultFilter": false, + "memberLayout": "separatePages", + "namespaceLayout": "flattened", + "enumSortOrder": "declaringOrder", + "includeExplicitInterfaceImplementations": true } ], "build": { diff --git a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRefreshEventArgs.cs b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRefreshEventArgs.cs index 287aade..5963e6c 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRefreshEventArgs.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRefreshEventArgs.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRevokeEventArgs.cs b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRevokeEventArgs.cs index dca8114..1f7c202 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRevokeEventArgs.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AccessTokenRevokeEventArgs.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeExchangeEventArgs.cs b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeExchangeEventArgs.cs index 9d469e8..bfd0bd4 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeExchangeEventArgs.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeExchangeEventArgs.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeReceiveEventArgs.cs b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeReceiveEventArgs.cs index 723e7d7..def7a94 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeReceiveEventArgs.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventArgs/AuthorizationCodeReceiveEventArgs.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventArgs/DiscordOAuth2EventArgs.cs b/DisCatSharp.Extensions.OAuth2Web/EventArgs/DiscordOAuth2EventArgs.cs index 799064b..a4b7ced 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventArgs/DiscordOAuth2EventArgs.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventArgs/DiscordOAuth2EventArgs.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventHandling/AuthorizationCodeEventWaiter.cs b/DisCatSharp.Extensions.OAuth2Web/EventHandling/AuthorizationCodeEventWaiter.cs index b162d9e..a134391 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventHandling/AuthorizationCodeEventWaiter.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventHandling/AuthorizationCodeEventWaiter.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeCollectRequest.cs b/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeCollectRequest.cs index ef225f9..c0ad383 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeCollectRequest.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeCollectRequest.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeMatchRequest.cs b/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeMatchRequest.cs index 233e54f..ecd35bd 100644 --- a/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeMatchRequest.cs +++ b/DisCatSharp.Extensions.OAuth2Web/EventHandling/Requests/AuthorizationCodeMatchRequest.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/ExtensionMethods.cs b/DisCatSharp.Extensions.OAuth2Web/ExtensionMethods.cs index 1a90b0a..699634e 100644 --- a/DisCatSharp.Extensions.OAuth2Web/ExtensionMethods.cs +++ b/DisCatSharp.Extensions.OAuth2Web/ExtensionMethods.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/OAuth2Result.cs b/DisCatSharp.Extensions.OAuth2Web/OAuth2Result.cs index 9c66e5d..ff5333c 100644 --- a/DisCatSharp.Extensions.OAuth2Web/OAuth2Result.cs +++ b/DisCatSharp.Extensions.OAuth2Web/OAuth2Result.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebConfiguration.cs b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebConfiguration.cs index 3697cb4..e267d7b 100644 --- a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebConfiguration.cs +++ b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebConfiguration.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtension.cs b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtension.cs index 0b8cbc6..9f19904 100644 --- a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtension.cs +++ b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtension.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtensionUtilities.cs b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtensionUtilities.cs index 2c57218..daa1525 100644 --- a/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtensionUtilities.cs +++ b/DisCatSharp.Extensions.OAuth2Web/OAuth2WebExtensionUtilities.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.SimpleMusicCommands/ExtensionMethods.cs b/DisCatSharp.Extensions.SimpleMusicCommands/ExtensionMethods.cs index 0e48c00..eb79fb5 100644 --- a/DisCatSharp.Extensions.SimpleMusicCommands/ExtensionMethods.cs +++ b/DisCatSharp.Extensions.SimpleMusicCommands/ExtensionMethods.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.SimpleMusicCommands/SimpleMusicCommandsExtension.cs b/DisCatSharp.Extensions.SimpleMusicCommands/SimpleMusicCommandsExtension.cs index 0550fe1..b2508c9 100644 --- a/DisCatSharp.Extensions.SimpleMusicCommands/SimpleMusicCommandsExtension.cs +++ b/DisCatSharp.Extensions.SimpleMusicCommands/SimpleMusicCommandsExtension.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.Translations.ApplicationCommands/DisCatSharp.Extensions.Translations.ApplicationCommands.csproj b/DisCatSharp.Extensions.Translations.ApplicationCommands/DisCatSharp.Extensions.Translations.ApplicationCommands.csproj new file mode 100644 index 0000000..77272a2 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.ApplicationCommands/DisCatSharp.Extensions.Translations.ApplicationCommands.csproj @@ -0,0 +1,38 @@ + + + + + + + + + + + + DisCatSharp.Extensions.Translations.ApplicationCommands + DisCatSharp.Extensions.Translations.ApplicationCommands + + + + DisCatSharp.Extensions.Translations.ApplicationCommands + + DisCatSharp.Extensions.Translations.ApplicationCommands + + Extension making it easy to use localizations in ApplicationCommands commands. + + DisCatSharp,DisCatSharp Extension,Translations,Localization,ApplicationCommands,Discord,Bots,Discord Bots,AITSYS,Net8,Net9,Net10 + + + + + + + + + + + + + + + diff --git a/DisCatSharp.Extensions.Translations.ApplicationCommands/ExtensionMethods.cs b/DisCatSharp.Extensions.Translations.ApplicationCommands/ExtensionMethods.cs new file mode 100644 index 0000000..51339df --- /dev/null +++ b/DisCatSharp.Extensions.Translations.ApplicationCommands/ExtensionMethods.cs @@ -0,0 +1,64 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DisCatSharp.ApplicationCommands.Context; + +namespace DisCatSharp.Extensions.Translations.ApplicationCommands; + +/// +/// Defines various extensions specific to Translator. +/// +public static class ExtensionMethods +{ + /// + /// Translates a key using the locale from the . + /// + /// The interaction context. + /// The translation key. + /// The placeholders to replace in the translation. + /// Whether to force the guild locale. + /// The translated string. + public static string T(this InteractionContext ctx, string key, object? placeholders = null, bool forceGuildLocale = false) + => ctx.Interaction.T(key, placeholders); + + /// + /// Translates a key using the locale from the . + /// + /// The context menu context. + /// The translation key. + /// The placeholders to replace in the translation. + /// Whether to force the guild locale. + /// The translated string. + public static string T(this ContextMenuContext ctx, string key, object? placeholders = null, bool forceGuildLocale = false) + => ctx.Interaction.T(key, placeholders); + + /// + /// Translates a key using the locale from the . + /// + /// The autocomplete context. + /// The translation key. + /// The placeholders to replace in the translation. + /// Whether to force the guild locale. + /// The translated string. + public static string T(this AutocompleteContext ctx, string key, object? placeholders = null, bool forceGuildLocale = false) + => ctx.Interaction.T(key, placeholders); +} diff --git a/DisCatSharp.Extensions.Translations.CommandsNext/DisCatSharp.Extensions.Translations.CommandsNext.csproj b/DisCatSharp.Extensions.Translations.CommandsNext/DisCatSharp.Extensions.Translations.CommandsNext.csproj new file mode 100644 index 0000000..2b1924b --- /dev/null +++ b/DisCatSharp.Extensions.Translations.CommandsNext/DisCatSharp.Extensions.Translations.CommandsNext.csproj @@ -0,0 +1,39 @@ + + + + + + + + + + + + DisCatSharp.Extensions.Translations.CommandsNext + DisCatSharp.Extensions.Translations.CommandsNext + + + + DisCatSharp.Extensions.Translations.CommandsNext + + DisCatSharp.Extensions.Translations.CommandsNext + + Extension making it easy to use localizations in CommandsNext commands. + + DisCatSharp,DisCatSharp Extension,Translations,Localization,CommandsNext,Discord,Bots,Discord Bots,AITSYS,Net8,Net9,Net10 + + + + + + + + + + + + + + + + diff --git a/DisCatSharp.Extensions.Translations.CommandsNext/ExtensionMethods.cs b/DisCatSharp.Extensions.Translations.CommandsNext/ExtensionMethods.cs new file mode 100644 index 0000000..ee4c434 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.CommandsNext/ExtensionMethods.cs @@ -0,0 +1,48 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using DisCatSharp.CommandsNext; + +namespace DisCatSharp.Extensions.Translations.CommandsNext; + +/// +/// Defines various extensions specific to Translator. +/// +public static class ExtensionMethods +{ + /// + /// Translates a key using the locale from the . + /// + /// The autocomplete context. + /// The translation key. + /// The placeholders to replace in the translation. + /// Whether to force the guild locale. + /// The translated string. + public static string T(this CommandContext ctx, string key, object? placeholders = null, bool forceGuildLocale = false) + { + var engine = ctx.Client.GetExtension()!.TranslationEngine; + var locale = engine.DefaultLocale; + if (forceGuildLocale || !string.IsNullOrWhiteSpace(ctx.Guild?.PreferredLocale)) + locale = ctx.Guild?.PreferredLocale; + return engine.TLocale(locale, key, placeholders); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/AuditReport.cs b/DisCatSharp.Extensions.Translations.Manager/AuditReport.cs new file mode 100644 index 0000000..3aaa2f3 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/AuditReport.cs @@ -0,0 +1,158 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record AuditReport +{ + public required DateTimeOffset GeneratedAt { get; init; } + public required string RepoRoot { get; init; } + public required string SourceDirectory { get; init; } + public required string StringsPath { get; init; } + public required int UsedKeysCount { get; init; } + public required int DefinedKeysCount { get; init; } + public required List MissingKeys { get; init; } + public required List UnusedKeys { get; init; } + public required List DuplicateKeys { get; init; } + public required Dictionary> DuplicateEnUsValues { get; init; } + public required Dictionary LocaleMissingCounts { get; init; } + public required List DynamicKeyUsages { get; init; } + public required Dictionary> KeyUsages { get; init; } + + public static AuditReport Build(TranslationData translations, ScanResult scan, TranslationsManagerConfiguration configuration, string stringsPath) + { + var repoRoot = Path.GetFullPath(configuration.RepoRoot); + var sourceDirectory = Path.GetFullPath(configuration.SourceDirectory); + var resolvedStringsPath = Path.GetFullPath(stringsPath); + var definedKeys = translations.Map.Keys.ToHashSet(StringComparer.Ordinal); + var missing = scan.Keys.Where(k => !definedKeys.Contains(k)).OrderBy(k => k).ToList(); + var dynamicPrefixes = BuildDynamicPrefixes(scan.DynamicUsages); + var unused = definedKeys + .Where(k => !scan.Keys.Contains(k) && !IsCoveredByDynamic(k, dynamicPrefixes)) + .OrderBy(k => k) + .ToList(); + + var usageLookup = scan.Usages + .GroupBy(u => u.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(u => u.File).ThenBy(u => u.Line).ToList(), StringComparer.OrdinalIgnoreCase); + + var enPairs = translations.Map + .Select(kv => kv.Value.TryGetValue("en-US", out var enValue) ? (kv.Key, Value: enValue) : (Key: null, Value: null)) + .Where(x => x.Key is not null && !string.IsNullOrWhiteSpace(x.Value)) + .Select(x => (Key: x.Key!, Value: x.Value!)); + + var enDuplicates = enPairs + .GroupBy(x => x.Value) + .Where(g => g.Count() > 1) + .ToDictionary(g => g.Key, g => g.Select(item => item.Key).OrderBy(x => x).ToList(), StringComparer.Ordinal); + + var localeMissing = BuildLocaleMissing(translations.Map); + + return new AuditReport + { + GeneratedAt = DateTimeOffset.UtcNow, + RepoRoot = repoRoot, + SourceDirectory = sourceDirectory, + StringsPath = resolvedStringsPath, + UsedKeysCount = scan.Keys.Count, + DefinedKeysCount = translations.Map.Count, + MissingKeys = missing, + UnusedKeys = unused, + DuplicateKeys = [.. translations.DuplicateKeys], + DuplicateEnUsValues = enDuplicates, + LocaleMissingCounts = localeMissing, + DynamicKeyUsages = [.. scan.DynamicUsages], + KeyUsages = usageLookup + }; + } + + private static Dictionary BuildLocaleMissing(Dictionary> map) + { + var locales = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var value in map.Values) + { + foreach (var locale in value.Keys) + locales.Add(locale); + } + + var missing = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var locale in locales) + { + var count = map.Count(kv => !kv.Value.TryGetValue(locale, out var text) || string.IsNullOrWhiteSpace(text)); + missing[locale] = count; + } + + return missing; + } + + private static IReadOnlyList BuildDynamicPrefixes(IReadOnlyList dynamicUsages) + { + var prefixes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dyn in dynamicUsages) + { + var prefix = ExtractPrefix(dyn.Expression); + if (!string.IsNullOrWhiteSpace(prefix)) + prefixes.Add(prefix); + } + + return prefixes.ToList(); + } + + private static bool IsCoveredByDynamic(string key, IReadOnlyList prefixes) + { + foreach (var prefix in prefixes) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string ExtractPrefix(string expression) + { + if (string.IsNullOrWhiteSpace(expression)) + return string.Empty; + + // Trim leading interpolation markers + var expr = expression.Trim(); + if (expr.StartsWith("$") && expr.Length > 1) + expr = expr.Substring(1); + if (expr.StartsWith("@$") && expr.Length > 2) + expr = expr.Substring(2); + expr = expr.TrimStart('"'); + + var brace = expr.IndexOf('{'); + if (brace >= 0) + expr = expr.Substring(0, brace); + + // Strip trailing quote if still present + expr = expr.TrimEnd('"'); + + // Keep only prefixes that look like translation key prefixes (must contain a dot) + return expr.Contains('.') ? expr : string.Empty; + } +} \ No newline at end of file diff --git a/DisCatSharp.Extensions.Translations.Manager/AuditReportProvider.cs b/DisCatSharp.Extensions.Translations.Manager/AuditReportProvider.cs new file mode 100644 index 0000000..359c704 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/AuditReportProvider.cs @@ -0,0 +1,122 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to do so. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed class AuditReportProvider +{ + private readonly TranslationsManagerConfiguration _configuration; + private readonly StringsStore _store; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + private AuditReport? _cached; + private DateTimeOffset _cacheExpiresAt; + + public AuditReportProvider(TranslationsManagerConfiguration configuration, StringsStore store, ILogger logger) + { + this._configuration = configuration; + this._store = store; + this._logger = logger; + } + + public async Task GetReportAsync(CancellationToken cancellationToken = default) + { + await this._gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._cached is not null && DateTimeOffset.UtcNow < this._cacheExpiresAt) + return this._cached; + + var translations = this.LoadTranslations(); + var scan = Scanner.ScanSource(this.ResolveSourceDirectory(), this.ResolveRepoRoot()); + var report = AuditReport.Build(translations, scan, this._configuration, this._store.Path); + + if (this._configuration.WriteAuditToDisk) + this.WriteReport(report); + + this._cached = report; + this._cacheExpiresAt = DateTimeOffset.UtcNow.Add(this._configuration.ReportCacheDuration); + return report; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Failed to build translations audit report."); + throw; + } + finally + { + this._gate.Release(); + } + } + + private TranslationData LoadTranslations() + { + return !File.Exists(this._store.Path) + ? new TranslationData( + new Dictionary>(StringComparer.OrdinalIgnoreCase), + [], + new Dictionary(StringComparer.OrdinalIgnoreCase)) + : TranslationData.Load(this._store.Path); + } + + private string ResolveRepoRoot() + => Path.GetFullPath(this._configuration.RepoRoot); + + private string ResolveSourceDirectory() + { + var src = this._configuration.SourceDirectory; + if (!Path.IsPathRooted(src)) + src = Path.Combine(this.ResolveRepoRoot(), src); + return Path.GetFullPath(src); + } + + private string ResolveAuditOutputPath() + { + var output = this._configuration.AuditOutputPath; + if (!Path.IsPathRooted(output)) + output = Path.Combine(this.ResolveRepoRoot(), output); + return Path.GetFullPath(output); + } + + private void WriteReport(AuditReport report) + { + var outputPath = this.ResolveAuditOutputPath(); + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + var json = JsonSerializer.Serialize(report, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + File.WriteAllText(outputPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/DisCatSharp.Extensions.Translations.Manager.csproj b/DisCatSharp.Extensions.Translations.Manager/DisCatSharp.Extensions.Translations.Manager.csproj new file mode 100644 index 0000000..a87d6e3 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/DisCatSharp.Extensions.Translations.Manager.csproj @@ -0,0 +1,67 @@ + + + + + + + + + + + + DisCatSharp.Extensions.Translations.Manager + DisCatSharp.Extensions.Translations.Manager + + + + DisCatSharp.Extensions.Translations.Manager + + DisCatSharp.Extensions.Translations.Manager + + Extention for DisCatSharp.Extensions.Translation to manage translations for your Discord + bot with ease. + + Provides an easy-to-use webinterface for managing translations, loading from various + sources, and integrating with DisCatSharp-based Discord bots. + + DisCatSharp,DisCatSharp Extension,Translations,Localization,Web,Translation + Manager,Discord,Bots,Discord Bots,AITSYS,Net8,Net9,Net10 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DisCatSharp.Extensions.Translations.Manager/DynamicUsage.cs b/DisCatSharp.Extensions.Translations.Manager/DynamicUsage.cs new file mode 100644 index 0000000..ebfc7dd --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/DynamicUsage.cs @@ -0,0 +1,25 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record DynamicUsage(string File, int Line, string Method, string Reason, string Expression); diff --git a/DisCatSharp.Extensions.Translations.Manager/ITranslationsReloadHandler.cs b/DisCatSharp.Extensions.Translations.Manager/ITranslationsReloadHandler.cs new file mode 100644 index 0000000..c7918c3 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/ITranslationsReloadHandler.cs @@ -0,0 +1,67 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to do so. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Threading; +using System.Threading.Tasks; + +namespace DisCatSharp.Extensions.Translations.Manager; + +/// +/// Provides a hook to refresh translations after the backing store changes. +/// +internal interface ITranslationsReloadHandler +{ + Task ReloadAsync(CancellationToken cancellationToken = default); +} + +internal sealed class NoOpTranslationsReloadHandler : ITranslationsReloadHandler +{ + public Task ReloadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +internal sealed class DiscordTranslationsReloadHandler : ITranslationsReloadHandler +{ + private readonly DiscordClient? _client; + private readonly DiscordShardedClient? _shardedClient; + + public DiscordTranslationsReloadHandler(DiscordClient client) + { + this._client = client; + } + + public DiscordTranslationsReloadHandler(DiscordShardedClient client) + { + this._shardedClient = client; + } + + public async Task ReloadAsync(CancellationToken cancellationToken = default) + { + if (this._client is not null) + { + var extension = this._client.GetTranslationsExtension(); + extension?.TranslationEngine.Reload(); + } + + if (this._shardedClient is not null) + { + var extensions = await this._shardedClient.GetTranslationsExtension().ConfigureAwait(false); + foreach (var kvp in extensions) + kvp.Value?.TranslationEngine.Reload(); + } + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/LocaleHelper.cs b/DisCatSharp.Extensions.Translations.Manager/LocaleHelper.cs new file mode 100644 index 0000000..95ce893 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/LocaleHelper.cs @@ -0,0 +1,125 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Reflection; + +using DisCatSharp.Entities; + +using Microsoft.CodeAnalysis; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal static class LocaleHelper +{ + internal static string[] GetValidLocales() + { + var values = new List(); + + foreach (var field in typeof(DiscordLocales).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = ToLocaleString(field.GetValue(null)); + if (!string.IsNullOrWhiteSpace(value)) + values.Add(value); + } + + foreach (var prop in typeof(DiscordLocales).GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (!prop.CanRead) + continue; + var value = ToLocaleString(prop.GetValue(null)); + if (!string.IsNullOrWhiteSpace(value)) + values.Add(value); + } + + values.AddRange(s_fallbackLocales); + + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, Comparer.Create((l, r) => PrimaryFirstThenAlpha(l, r, "en-US"))) + .ToArray(); + } + + private static string? ToLocaleString(object? value) => value switch + { + null => null, + string s => s, + _ => value.ToString() + }; + + private static readonly string[] s_fallbackLocales = + [ + "da", "de", "en-GB", "en-US", "es-ES", "fr", "hr", "it", "lt", "hu", "nl", "no", + "pl", "pt-BR", "ro", "fi", "sv-SE", "vi", "tr", "cs", "el", "bg", "ru", "uk", + "hi", "th", "zh-CN", "ja", "zh-TW", "ko" + ]; + + private static int PrimaryFirstThenAlpha(string? left, string? right, string primary = "en-US") + { + var l = left ?? string.Empty; + var r = right ?? string.Empty; + return string.Equals(l, r, StringComparison.OrdinalIgnoreCase) + ? 0 + : string.Equals(l, primary, StringComparison.OrdinalIgnoreCase) + ? -1 + : string.Equals(r, primary, StringComparison.OrdinalIgnoreCase) ? 1 : string.Compare(l, r, StringComparison.OrdinalIgnoreCase); + } + + internal static bool ValidateLocales(Dictionary? translations, string[] allowed, out string? error) + { + if (translations is null) + { + error = null; + return true; + } + + if (!translations.ContainsKey("en-US")) + { + error = "Primary locale en-US is required."; + return false; + } + + var set = new HashSet(allowed, StringComparer.OrdinalIgnoreCase); + foreach (var locale in translations.Keys) + { + if (!set.Contains(locale)) + { + error = $"Invalid locale: {locale}"; + return false; + } + } + + // optional deeper validation via DiscordApplicationCommandLocalization + var localization = new DiscordApplicationCommandLocalization(); + foreach (var locale in translations.Keys) + { + if (!localization.Validate(locale)) + { + error = $"Invalid locale: {locale}"; + return false; + } + } + + error = null; + return true; + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/ScanResult.cs b/DisCatSharp.Extensions.Translations.Manager/ScanResult.cs new file mode 100644 index 0000000..e9d831e --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/ScanResult.cs @@ -0,0 +1,25 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record ScanResult(HashSet Keys, IReadOnlyList DynamicUsages, IReadOnlyList Usages, int FilesScanned); diff --git a/DisCatSharp.Extensions.Translations.Manager/Scanner.cs b/DisCatSharp.Extensions.Translations.Manager/Scanner.cs new file mode 100644 index 0000000..e5768aa --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/Scanner.cs @@ -0,0 +1,81 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; + +using Microsoft.CodeAnalysis.CSharp; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal static class Scanner +{ + public static ScanResult ScanSource(string sourceDir, string repoRoot) + { + var usedKeys = new HashSet(StringComparer.Ordinal); + var dynamicUsages = new List(); + var usages = new List(); + if (!Directory.Exists(sourceDir)) + return new ScanResult(usedKeys, dynamicUsages, usages, 0); + + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + MatchCasing = MatchCasing.CaseInsensitive + }; + var files = Directory.EnumerateFiles(sourceDir, "*.cs", options); + var processed = 0; + + foreach (var file in files) + { + if (IsIgnored(file)) + continue; + + processed++; + // Skip the translator definition file to avoid self-references showing as dynamic keys + if (string.Equals(Path.GetFileName(file), "TranslationEngine.cs", StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.Equals(Path.GetFileName(file), "ExtensionMethods.cs", StringComparison.OrdinalIgnoreCase)) + continue; + + var text = File.ReadAllText(file); + var tree = CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(LanguageVersion.Preview)); + var walker = new TranslationUsageWalker(file, repoRoot, usedKeys, dynamicUsages, usages); + walker.Visit(tree.GetRoot()); + } + + return new ScanResult(usedKeys, dynamicUsages, usages, processed); + } + + private static bool IsIgnored(string filePath) + { + static bool ContainsSegment(string path, string segment) => path.Contains($"{Path.DirectorySeparatorChar}{segment}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase); + + return ContainsSegment(filePath, "bin") + || ContainsSegment(filePath, "obj") + || ContainsSegment(filePath, ".git") + || ContainsSegment(filePath, "node_modules"); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/StringsMutation.cs b/DisCatSharp.Extensions.Translations.Manager/StringsMutation.cs new file mode 100644 index 0000000..9e7b1d0 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/StringsMutation.cs @@ -0,0 +1,25 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record StringsMutation(string Key, Dictionary? Translations); diff --git a/DisCatSharp.Extensions.Translations.Manager/StringsStore.cs b/DisCatSharp.Extensions.Translations.Manager/StringsStore.cs new file mode 100644 index 0000000..ff6e4db --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/StringsStore.cs @@ -0,0 +1,57 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed class StringsStore(string path, string file, ITranslationsReloadHandler reloadHandler) +{ + private readonly object _gate = new(); + private readonly ITranslationsReloadHandler _reloadHandler = reloadHandler; + public string Path { get; } = System.IO.Path.Join(path, file); + + public Dictionary> Load() + { + lock (this._gate) + { + if (!File.Exists(this.Path)) + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + var data = TranslationData.Load(this.Path); + return data.Map; + } + } + + public async Task SaveAsync(Dictionary> map, bool sort = false, string? primaryLocale = null, CancellationToken cancellationToken = default) + { + lock (this._gate) + { + TranslationData.Save(this.Path, map, sort, primaryLocale); + } + + await this._reloadHandler.ReloadAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/TranslationData.cs b/DisCatSharp.Extensions.Translations.Manager/TranslationData.cs new file mode 100644 index 0000000..7b83946 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/TranslationData.cs @@ -0,0 +1,113 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record TranslationData( + Dictionary> Map, + IReadOnlyList DuplicateKeys, + IReadOnlyDictionary KeyCounts) +{ + public static TranslationData Load(string path) + { + var text = File.ReadAllText(path); + var bytes = Encoding.UTF8.GetBytes(text); + var duplicateKeys = new List(); + var keyCounts = new Dictionary(StringComparer.Ordinal); + + var reader = new Utf8JsonReader(bytes, new JsonReaderOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName && reader.CurrentDepth == 1) + { + var key = reader.GetString() ?? string.Empty; + if (keyCounts.TryGetValue(key, out var count)) + { + keyCounts[key] = count + 1; + if (count == 1) + duplicateKeys.Add(key); + } + else + { + keyCounts[key] = 1; + } + } + } + + var map = JsonSerializer.Deserialize>>(text, new JsonSerializerOptions + { + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }) ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); + + return new TranslationData(new Dictionary>(map, StringComparer.OrdinalIgnoreCase), duplicateKeys, keyCounts); + } + + public static void Save(string path, Dictionary> map, bool sort = false, string? primaryLocale = null) + { + IEnumerable>> outer = map; + if (sort) + outer = outer.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase); + + var ordered = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var pair in outer) + { + IEnumerable> inner = pair.Value; + if (primaryLocale is not null) + inner = inner.OrderBy(p => p.Key, Comparer.Create((l, r) => PrimaryFirstThenAlpha(l, r, primaryLocale))); + else if (sort) + inner = inner.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase); + + ordered[pair.Key] = inner.ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase); + } + + var json = JsonSerializer.Serialize(ordered, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.Never + }); + + File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private static int PrimaryFirstThenAlpha(string? left, string? right, string primary = "en-US") + { + var l = left ?? string.Empty; + var r = right ?? string.Empty; + return string.Equals(l, r, StringComparison.OrdinalIgnoreCase) + ? 0 + : string.Equals(l, primary, StringComparison.OrdinalIgnoreCase) + ? -1 + : string.Equals(r, primary, StringComparison.OrdinalIgnoreCase) ? 1 : string.Compare(l, r, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/TranslationManager.cs b/DisCatSharp.Extensions.Translations.Manager/TranslationManager.cs new file mode 100644 index 0000000..2f58c91 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/TranslationManager.cs @@ -0,0 +1,163 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; + +using DisCatSharp; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DisCatSharp.Extensions.Translations.Manager; + +public sealed class TranslationManager : IAsyncDisposable +{ + private readonly TranslationsManagerConfiguration _configuration; + private readonly ITranslationsReloadHandler _reloadHandler; + private readonly WebApplication _app; + private readonly ILogger _logger; + + public TranslationManager(DiscordClient client, TranslationsManagerConfiguration configuration) + : this(configuration, new DiscordTranslationsReloadHandler(client)) + { } + + public TranslationManager(DiscordShardedClient client, TranslationsManagerConfiguration configuration) + : this(configuration, new DiscordTranslationsReloadHandler(client)) + { } + + internal TranslationManager(TranslationsManagerConfiguration configuration, ITranslationsReloadHandler? reloadHandler = null) + { + this._configuration = configuration; + this._reloadHandler = reloadHandler ?? new NoOpTranslationsReloadHandler(); + + var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + var translationsPath = this._configuration.TranslationsFolder; + if (!Path.IsPathRooted(translationsPath)) + translationsPath = Path.Combine(this._configuration.RepoRoot, translationsPath); + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = AppContext.BaseDirectory, + WebRootPath = webRoot + }); + builder.WebHost.UseUrls($"http://localhost:{this._configuration.ServerPort}"); + builder.Services.AddSingleton(this._configuration); + builder.Services.AddSingleton(this._reloadHandler); + builder.Services.AddSingleton(sp => new StringsStore(translationsPath, this._configuration.TranslationsFileName, this._reloadHandler)); + builder.Services.AddSingleton(LocaleHelper.GetValidLocales()); + builder.Services.AddSingleton(); + builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); + + this._app = builder.Build(); + this._logger = this._app.Services.GetRequiredService>(); + this.ConfigureEndpoints(); + } + + private void ConfigureEndpoints() + { + this._app.UseDefaultFiles(); + this._app.UseStaticFiles(); + + this._app.MapGet("/api/report", async (AuditReportProvider provider, CancellationToken cancellationToken) => + { + var report = await provider.GetReportAsync(cancellationToken).ConfigureAwait(false); + return Results.Json(report); + }); + + this._app.MapGet("/api/strings", (StringsStore store) => Results.Json(store.Load())); + + this._app.MapGet("/api/locales", (string[] locales) => Results.Json(locales)); + + this._app.MapPost("/api/strings", async (StringsStore store, StringsMutation payload, string[] locales, CancellationToken cancellationToken) => + { + if (payload is null || string.IsNullOrWhiteSpace(payload.Key)) + return Results.BadRequest(new { message = "Key is required." }); + if (!LocaleHelper.ValidateLocales(payload.Translations, locales, out var error)) + return Results.BadRequest(new { message = error }); + + var map = store.Load(); + if (map.ContainsKey(payload.Key)) + return Results.Conflict(new { message = "Key already exists." }); + map[payload.Key] = payload.Translations ?? []; + await store.SaveAsync(map, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }); + + this._app.MapPut("/api/strings/{key}", async (StringsStore store, string key, StringsMutation payload, string[] locales, CancellationToken cancellationToken) => + { + if (payload is null) + return Results.BadRequest(new { message = "Payload required." }); + if (!LocaleHelper.ValidateLocales(payload.Translations, locales, out var error)) + return Results.BadRequest(new { message = error }); + + var map = store.Load(); + map[key] = payload.Translations ?? []; + await store.SaveAsync(map, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }); + + this._app.MapPost("/api/strings/format", async (StringsStore store, CancellationToken cancellationToken) => + { + var map = store.Load(); + await store.SaveAsync(map, sort: false, primaryLocale: this._configuration.DefaultLocale, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }); + + this._app.MapDelete("/api/strings/{key}", async (StringsStore store, string key, CancellationToken cancellationToken) => + { + var map = store.Load(); + if (!map.Remove(key)) + return Results.NotFound(); + await store.SaveAsync(map, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(); + }); + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + this._logger.LogInformation("Serving translation UI at http://localhost:{Port}", this._configuration.ServerPort); + await this._app.StartAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await this.StartAsync(cancellationToken).ConfigureAwait(false); + await this._app.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await this._app.StopAsync(cancellationToken).ConfigureAwait(false); + this._logger.LogInformation("Translation UI server stopped."); + } + + public async ValueTask DisposeAsync() + => await this._app.DisposeAsync(); +} diff --git a/DisCatSharp.Extensions.Translations.Manager/TranslationUsageWalker.cs b/DisCatSharp.Extensions.Translations.Manager/TranslationUsageWalker.cs new file mode 100644 index 0000000..2484dfb --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/TranslationUsageWalker.cs @@ -0,0 +1,141 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed class TranslationUsageWalker(string filePath, string repoRoot, HashSet keys, List dynamicUsages, List usages) : CSharpSyntaxWalker +{ + private readonly string _filePath = filePath; + private readonly string _repoRoot = repoRoot; + private readonly HashSet _keys = keys; + private readonly List _dynamicUsages = dynamicUsages; + private readonly List _usages = usages; + + public override void VisitInvocationExpression(InvocationExpressionSyntax node) + { + var methodName = GetMethodName(node.Expression); + if (methodName is "T" or "TLocale") + this.HandleTranslationCall(methodName, node); + + base.VisitInvocationExpression(node); + } + + private void HandleTranslationCall(string methodName, InvocationExpressionSyntax node) + { + var args = node.ArgumentList?.Arguments; + if (args is null || args.Value.Count is 0) + { + this.RecordDynamic(node, methodName, "no arguments", null); + return; + } + + ExpressionSyntax? keyExpr = null; + if (string.Equals(methodName, "TLocale", StringComparison.Ordinal)) + { + var member = node.Expression as MemberAccessExpressionSyntax; + var receiverIsTranslator = member?.Expression is IdentifierNameSyntax id && string.Equals(id.Identifier.ValueText, "Translator", StringComparison.Ordinal); + var index = receiverIsTranslator && args.Value.Count > 1 ? 1 : 0; + if (index < args.Value.Count) + keyExpr = args.Value[index].Expression; + } + else + { + keyExpr = args.Value[0].Expression; + } + + if (keyExpr is null) + { + this.RecordDynamic(node, methodName, "dynamic key", null); + return; + } + + if (TryEvaluateString(keyExpr, out var key)) + { + this._keys.Add(key); + this.RecordUsage(node, methodName, key); + return; + } + + this.RecordDynamic(node, methodName, "dynamic key", keyExpr.ToString()); + } + + private static bool TryEvaluateString(ExpressionSyntax expr, out string value) + { + switch (expr) + { + case LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression): + value = literal.Token.ValueText; + return true; + case InterpolatedStringExpressionSyntax interpolated: + if (interpolated.Contents.All(c => c is InterpolatedStringTextSyntax)) + { + var sb = new StringBuilder(); + foreach (var part in interpolated.Contents.OfType()) + sb.Append(part.TextToken.ValueText); + value = sb.ToString(); + return true; + } + break; + case ParenthesizedExpressionSyntax paren: + return TryEvaluateString(paren.Expression, out value); + case BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.AddExpression): + if (TryEvaluateString(binary.Left, out var left) && TryEvaluateString(binary.Right, out var right)) + { + value = left + right; + return true; + } + break; + } + + value = string.Empty; + return false; + } + + private static string? GetMethodName(ExpressionSyntax expression) => expression switch + { + IdentifierNameSyntax id => id.Identifier.ValueText, + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText, + _ => null + }; + + private void RecordDynamic(SyntaxNode node, string methodName, string reason, string? expression) + { + var span = node.GetLocation().GetLineSpan(); + var line = span.StartLinePosition.Line + 1; + var relative = Path.GetRelativePath(this._repoRoot, this._filePath); + this._dynamicUsages.Add(new DynamicUsage(relative, line, methodName, reason, expression ?? string.Empty)); + } + + private void RecordUsage(SyntaxNode node, string methodName, string key) + { + var span = node.GetLocation().GetLineSpan(); + var line = span.StartLinePosition.Line + 1; + var relative = Path.GetRelativePath(this._repoRoot, this._filePath); + this._usages.Add(new UsageLocation(key, relative, line, methodName)); + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/TranslationsManagerConfiguration.cs b/DisCatSharp.Extensions.Translations.Manager/TranslationsManagerConfiguration.cs new file mode 100644 index 0000000..0bed7c5 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/TranslationsManagerConfiguration.cs @@ -0,0 +1,100 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.IO; + +using Microsoft.Extensions.DependencyInjection; + +namespace DisCatSharp.Extensions.Translations.Manager; + +/// +/// Represents a configuration for . +/// +public sealed class TranslationsManagerConfiguration : TranslationsConfiguration +{ + /// + /// Creates a new instance of . + /// + [ActivatorUtilitiesConstructor] + public TranslationsManagerConfiguration() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + [ActivatorUtilitiesConstructor] + public TranslationsManagerConfiguration(IServiceProvider provider) + { + this.ServiceProvider = provider; + } + + /// + /// Creates a new instance of , copying the properties of another configuration. + /// + /// Configuration the properties of which are to be copied. + public TranslationsManagerConfiguration(TranslationsManagerConfiguration other) + { + this.TranslationsFolder = other.TranslationsFolder; + this.TranslationsFileName = other.TranslationsFileName; + this.ServiceProvider = other.ServiceProvider; + this.DefaultLocale = other.DefaultLocale; + this.ServerPort = other.ServerPort; + this.RepoRoot = other.RepoRoot; + this.SourceDirectory = other.SourceDirectory; + this.AuditOutputPath = other.AuditOutputPath; + this.ReportCacheDuration = other.ReportCacheDuration; + this.WriteAuditToDisk = other.WriteAuditToDisk; + } + + /// + /// Sets the port the translation server will run on. Defaults to 5000. + /// + public int ServerPort { get; set; } = 5000; + + /// + /// Sets the repository root used for usage scanning and opening files from the UI. + /// Defaults to the current working directory. + /// + public string RepoRoot { get; set; } = Directory.GetCurrentDirectory(); + + /// + /// Sets the source directory scanned for translation usages. Defaults to "src" under . + /// + public string SourceDirectory { get; set; } = Path.Combine(Directory.GetCurrentDirectory(), "src"); + + /// + /// Optional path where the generated audit report is written. Defaults to translations folder. + /// + public string AuditOutputPath { get; set; } = Path.Combine("data", "translation_audit.json"); + + /// + /// Duration to cache audit reports before rescanning the repository. + /// + public TimeSpan ReportCacheDuration { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// When true, writes the audit report to on each request. + /// + public bool WriteAuditToDisk { get; set; } = false; +} diff --git a/DisCatSharp.Extensions.Translations.Manager/UsageLocation.cs b/DisCatSharp.Extensions.Translations.Manager/UsageLocation.cs new file mode 100644 index 0000000..749a9fe --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/UsageLocation.cs @@ -0,0 +1,25 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace DisCatSharp.Extensions.Translations.Manager; + +internal sealed record UsageLocation(string Key, string File, int Line, string Method); diff --git a/DisCatSharp.Extensions.Translations.Manager/build.cmd b/DisCatSharp.Extensions.Translations.Manager/build.cmd new file mode 100644 index 0000000..37c05a6 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/build.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +pushd %~dp0 +npm run build +popd diff --git a/DisCatSharp.Extensions.Translations.Manager/package-lock.json b/DisCatSharp.Extensions.Translations.Manager/package-lock.json new file mode 100644 index 0000000..7f9a4ba --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/package-lock.json @@ -0,0 +1,144 @@ +{ + "name": "DisCatSharp.Extensions.Translations.Manager", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "terser": "^5.16.1", + "typescript": "5.9.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/package.json b/DisCatSharp.Extensions.Translations.Manager/package.json new file mode 100644 index 0000000..a9867a3 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/package.json @@ -0,0 +1,11 @@ +{ + "devDependencies": { + "typescript": "5.9.3", + "terser": "^5.16.1" + }, + "scripts": { + "build:ts": "npx tsc -p wwwroot/tsconfig.json", + "build:minify": "npx terser wwwroot/app.js -b --rename -c -m --source-map \"content=wwwroot/app.js.map,url=app.js.map\" -o wwwroot/app.js", + "build": "npm run build:ts && npm run build:minify" + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js new file mode 100644 index 0000000..8bc386b --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js @@ -0,0 +1,2 @@ +"use strict";(()=>{const e="en-US",t=["da","de","en-GB","en-US","es-ES","fr","hr","it","lt","hu","nl","no","pl","pt-BR","ro","fi","sv-SE","vi","tr","cs","el","bg","ru","uk","hi","th","zh-CN","ja","zh-TW","ko"],n=localStorage.getItem("vscodeScheme");let s="vscode-insiders"===n||"vscode"===n?n:navigator.userAgent.includes("Insider")?"vscode-insiders":"vscode";const a="https://github.com/Aiko-IT-Systems/ScWikeloGrind/blob/main",o={report:null,strings:{},locales:[],selected:null,realKeys:new Set},c=e=>document.getElementById(e),i=c("keyList"),r=c("detailsBody"),l=c("summary"),d=c("scheme"),u=c("search"),m=c("filter"),p=c("downloadStrings"),f=c("modalHost");async function g(){const[n,s,a]=await Promise.all([L("/api/report"),L("/api/strings"),L("/api/locales")]);o.report=n,o.realKeys=new Set(Object.keys(s)),o.strings={...s};(n?.missingKeys??[]).forEach(t=>{Object.prototype.hasOwnProperty.call(o.strings,t)||(o.strings[t]={[e]:""})}),o.locales=a&&a.length>0?a:t,l.textContent=`Used ${n.usedKeysCount} / Defined ${n.definedKeysCount} | Missing ${n.missingKeys.length} | Unused ${n.unusedKeys.length} | Dynamic ${n.dynamicKeyUsages.length}`,y(),h(o.selected)}function y(){if(!o.report)return;const t=u.value.toLowerCase(),n=m.value,s=function(t){const n=new Set;return n.add(e),Object.values(t).forEach(e=>{Object.keys(e||{}).forEach(e=>n.add(e))}),n}(o.strings),a=Object.keys(o.strings),c=o.report?.missingKeys??[],r=(o.report.dynamicKeyUsages||[]).map(e=>k(e)).filter(e=>e&&e.includes(".")),l=Array.from(new Set([...a,...r,...c])).sort((e,t)=>e.localeCompare(t));i.innerHTML="";const d=document.getElementById("key-item-template");l.forEach(a=>{const r=o.strings[a]||{},l=Object.values(r).join(" ").toLowerCase();if(t&&!a.toLowerCase().includes(t)&&!l.includes(t))return;const u=c.includes(a),m=o.report.dynamicKeyUsages.some(e=>k(e)===a),p=!m&&(!o.realKeys.has(a)||u),f=o.report.unusedKeys.includes(a),g=!r[e]||0===r[e].trim().length,y=!p&&!m&&(g||function(e,t){for(const n of t){const t=e?.[n]??"";if(!t||0===t.trim().length)return!0}return!1}(r,s)),v=m;if("kmissing"===n&&!p)return;if("lmissing"===n&&!y)return;if("unused"===n&&!f)return;if("dynamic"===n&&!v)return;const E=d.content.firstElementChild.cloneNode(!0);E.querySelector(".key-text").textContent=a;const w=E.querySelector(".badges");w.style.flexWrap="wrap",p&&w.append($("Missing Key","danger")),f&&w.append($("Unused","warn")),!p&&y&&w.append($("Missing Locale","danger")),v&&w.append($("Dynamic","success")),E.addEventListener("click",()=>h(a)),o.selected===a&&E.classList.add("selected"),i.append(E)})}function h(t){if(o.selected=t,!t)return r.className="details-empty",void(r.textContent="Select a key to edit");const n=o.realKeys.has(t),c=o.report?.dynamicKeyUsages.some(e=>k(e)===t),i=o.report?.dynamicKeyCandidates?.[t]??[],l=o.strings[t]||{[e]:""},d=w(Object.keys(l)),u=document.createElement("div");u.className="details";const m=document.createElement("div");if(m.innerHTML=`${t}${n||c?"":' Missing Key'}`,u.append(m),!n&&!c){const e=document.createElement("div");e.className="details-empty",e.textContent="This key is missing from strings.json. Fill in values and Save to create it.",u.append(e)}if(c){const e=document.createElement("div");e.className="details-empty",e.textContent="Dynamic key: manage concrete keys instead.",u.append(e)}else{const e=document.createElement("div");e.className="translations",d.forEach(t=>{e.append(v(t,l[t]??""))});const n=document.createElement("div");n.className="translation-add-row";const s=document.createElement("select"),a=w(o.locales),c=new Set(d),i=a.filter(e=>!c.has(e));s.innerHTML=`${i.map(e=>``).join("")}`;const r=document.createElement("button");r.textContent="Add locale",r.className="secondary",r.onclick=()=>{const t=s.value;t&&(e.querySelector(`[data-locale="${t}"]`)||(e.insertBefore(v(t,""),n),s.value=""))};const m=document.createElement("div");m.className="footer-actions";const p=document.createElement("button");p.textContent="Save",p.classList.add("success"),p.onclick=()=>async function(e,t){if(!await N("Save changes?","This will write updates to strings.json."))return;const n={};t.querySelectorAll(".translation-row").forEach(e=>{const t=e.dataset.locale,s=e.querySelector("textarea").value;t&&(n[t]=s)});const s=await fetch(`/api/strings/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:e,translations:n})});if(!s.ok){const e=await x(s);return void alert(`Save failed: ${e}`)}await g(),h(e),b("Saved")}(t,e);const f=document.createElement("button");f.textContent="Delete",f.className="danger",f.onclick=()=>async function(e){const t=await N("Delete key?",`This removes '${e}' from strings.json.`);if(!t)return;const n=await fetch(`/api/strings/${encodeURIComponent(e)}`,{method:"DELETE"});if(!n.ok){const e=await x(n);return void alert(`Delete failed: ${e}`)}await g(),h(null),b("Deleted")}(t),m.append(p,f),n.append(s,r,m),e.append(n),u.append(e)}const p=document.createElement("div");p.className="usage",p.innerHTML="Usages";const f=document.createElement("div"),y=o.report?.keyUsages[t]||[],E=(o.report?.dynamicKeyUsages||[]).filter(e=>k(e)===t),$=document.getElementById("usage-row-template"),L=(o.report?.repoRoot||"").replace(/\\/g,"/");if(y.length>0&&y.forEach(e=>{const t=$.content.firstElementChild.cloneNode(!0);t.querySelector(".usage-file").textContent=`${e.file} : ${e.line}`;const n=`${L}/${e.file}`.replace(/\\/g,"/");S(t).onclick=()=>{const t=`${s}://file/${encodeURI(n)}:${e.line}`;window.open(t,"_blank")};const o=t.querySelector(".usage-copy"),c=`${a}/${e.file.replace(/\\/g,"/")}#L${e.line}`;o.onclick=()=>C(c,"Git link copied"),f.append(t)}),E.length>0&&E.forEach(e=>{const t=$.content.firstElementChild.cloneNode(!0);t.querySelector(".usage-file").textContent=`${e.file} : ${e.line} (dynamic: ${e.reason??""})`;const n=`${L}/${e.file}`.replace(/\\/g,"/");S(t).onclick=()=>{const t=`${s}://file/${encodeURI(n)}:${e.line}`;window.open(t,"_blank")};const o=t.querySelector(".usage-copy"),c=`${a}/${e.file.replace(/\\/g,"/")}#L${e.line}`;o.onclick=()=>C(c,"Git link copied"),f.append(t)}),0===y.length&&0===E.length){const e=document.createElement("div");e.textContent="No usages found.",e.className="details-empty",f.append(e)}if(p.append(f),u.append(p),c&&i.length>0){const e=document.createElement("div");e.className="usage",e.innerHTML="Candidate keys";const t=document.createElement("div");t.className="candidate-list",i.forEach(e=>{const n=document.createElement("div");n.className="details-empty",n.textContent=e,n.style.cursor="pointer",n.onclick=()=>h(e),t.append(n)}),e.append(t),u.append(e)}r.className="",r.innerHTML="",r.append(u)}function v(t,n){const s=document.createElement("div");s.className="translation-row",s.dataset.locale=t;const a=document.createElement("input");a.value=t,a.disabled=!0;const o=document.createElement("textarea");o.value=n??"",E(o),o.addEventListener("input",()=>E(o));const c=document.createElement("button");return c.textContent="Remove",c.className="danger",c.onclick=()=>{t!==e&&s.remove()},t===e&&(c.disabled=!0,c.textContent="Primary"),s.append(a,o,c),s}function E(e){e.style.height="auto",e.style.height=`${e.scrollHeight}px`}function w(t){return[...t].sort((t,n)=>t===n?0:t===e?-1:n===e?1:t.localeCompare(n))}function $(e,t){const n=document.createElement("span");return n.className=`badge ${t}`,n.textContent=e,n}function k(e){if(!e)return"";const t=e.expression||"";return function(e){let t=e.trim();t.startsWith("@$")?t=t.slice(2):t.startsWith("$")&&(t=t.slice(1));t=t.replace(/^"/,"").replace(/"$/,"");const n=t.indexOf("{");n>=0&&(t=t.slice(0,n));return t.trim()}(t)||t||""}function S(e){let t=e.querySelector(".usage-open");if(!t){t=document.createElement("button"),t.className="usage-open",t.textContent="Open in VS Code";(e.querySelector(".usage-actions")||e).prepend(t)}return t}function C(e,t){navigator.clipboard?.writeText(e).then(()=>b(t))}function b(e){const t=document.createElement("div");t.className="toast",t.textContent=e,document.body.append(t),setTimeout(()=>t.remove(),2e3)}function N(e,t){return new Promise(n=>{const s=document.createElement("div");s.className="modal-backdrop";const a=document.createElement("div");a.className="modal",a.innerHTML=`

${e}

${t}

`;const o=document.createElement("div");o.className="modal-actions";const c=document.createElement("button");c.className="ghost",c.textContent="Cancel";const i=document.createElement("button");i.className="danger",i.textContent="Confirm",c.onclick=()=>{s.remove(),n(!1)},i.onclick=()=>{s.remove(),n(!0)},o.append(c,i),a.append(o),s.append(a),f.append(s)})}async function L(e){const t=await fetch(e);if(!t.ok){const e=await x(t);throw new Error(e)}return t.json()}async function x(e){try{const t=await e.json();return t?.message?t.message:`${e.status} ${e.statusText}`}catch{return`${e.status} ${e.statusText}`}}c("refresh").addEventListener("click",async function(){try{await g(),b("Refreshed")}catch(e){const t=e?.message||String(e);alert(`Refresh failed: ${t}`)}}),c("addKey").addEventListener("click",async function(){const t=prompt("New translation key");if(!t)return;const n=await fetch("/api/strings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({key:t,translations:{[e]:""}})});if(!n.ok){const e=await x(n);return void alert(`Add failed: ${e}`)}await g(),h(t)}),c("beautify").addEventListener("click",async function(){if(!await N("Beautify strings.json?","This will rewrite strings.json with formatting."))return;const e=await fetch("/api/strings/format",{method:"POST"});if(!e.ok){const t=await x(e);return void alert(`Beautify failed: ${t}`)}await g(),b("Formatted")}),p.addEventListener("click",async function(){try{const e=await fetch("/api/strings");if(!e.ok)throw new Error(await x(e));const t=await e.json(),n=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),s=URL.createObjectURL(n),a=document.createElement("a");a.href=s,a.download="strings.json",a.click(),URL.revokeObjectURL(s),b("Downloaded strings.json")}catch(e){alert(`Download failed: ${e}`)}}),d.addEventListener("change",()=>{s=d.value,localStorage.setItem("vscodeScheme",s),h(o.selected)}),d.value=s,u.addEventListener("input",y),m.addEventListener("change",y),g()})(); +//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js.map b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js.map new file mode 100644 index 0000000..d916ab9 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.js.map @@ -0,0 +1 @@ +{"version":3,"names":["PRIMARY_LOCALE","FALLBACK_LOCALES","storedScheme","localStorage","getItem","vscodeScheme","navigator","userAgent","includes","REPO_BLOB_BASE","state","report","strings","locales","selected","realKeys","Set","el","id","document","getElementById","keyList","details","summary","scheme","search","filter","downloadBtn","modalHost","async","loadAll","Promise","all","fetchJson","Object","keys","missingKeys","forEach","key","prototype","hasOwnProperty","call","length","textContent","usedKeysCount","definedKeysCount","unusedKeys","dynamicKeyUsages","renderKeyList","renderDetails","query","value","toLowerCase","filterValue","expectedLocales","set","add","values","map","loc","collectExpectedLocales","translationKeys","dynamicKeys","d","keyFromDynamic","k","Array","from","sort","a","b","localeCompare","innerHTML","template","translations","valuesText","join","isReportedMissing","isDynamic","some","isMissingKey","has","isUnused","primaryMissing","trim","isMissingLocale","expected","text","hasMissingLocale","isDynamicBadge","node","content","firstElementChild","cloneNode","querySelector","badges","style","flexWrap","append","makeBadge","addEventListener","classList","className","existsInStrings","isDynamicKey","dynamicCandidates","dynamicKeyCandidates","orderLocales","wrapper","createElement","title","hint","dynNote","transWrap","locale","makeLocaleRow","addLocaleRow","addSelect","availableLocales","existing","options","l","addButton","onclick","insertBefore","actions","saveBtn","confirmModal","querySelectorAll","row","dataset","res","fetch","encodeURIComponent","method","headers","body","JSON","stringify","ok","msg","readError","alert","showToast","saveKey","deleteBtn","deleteKey","usageSection","usageList","usages","keyUsages","dynUsages","usageTemplate","repoRoot","replace","u","file","line","fullPath","ensureUsageOpen","codeUrl","encodeURI","window","open","gitBtn","gitUrl","copyText","reason","none","candidateSection","candidateList","cand","cursor","input","disabled","textarea","autosizeText","remove","height","scrollHeight","tone","span","dyn","raw","expression","expr","s","startsWith","slice","brace","indexOf","normalizeDynamicKey","btn","prepend","toastMessage","clipboard","writeText","then","message","toast","setTimeout","resolve","backdrop","modal","cancel","url","Error","json","data","status","statusText","err","String","prompt","blob","Blob","type","URL","createObjectURL","href","download","click","revokeObjectURL","setItem"],"sources":["https://translate-scwg.admin.aitsys.dev/app.ts"],"sourcesContent":["(() => {\r\n\ttype Locale = string;\r\n\ttype TranslationMap = Record;\r\n\ttype StringsMap = Record;\r\n\r\n\ttype AuditUsage = {\r\n\t\tfile: string;\r\n\t\tline: number;\r\n\t\treason?: string;\r\n\t\texpression?: string;\r\n\t};\r\n\r\n\ttype AuditReport = {\r\n\t\tusedKeysCount: number;\r\n\t\tdefinedKeysCount: number;\r\n\t\tmissingKeys: string[];\r\n\t\tunusedKeys: string[];\r\n\t\tdynamicKeyUsages: AuditUsage[];\r\n\t\tdynamicKeyCandidates?: Record;\r\n\t\tkeyUsages: Record;\r\n\t\trepoRoot: string;\r\n\t};\r\n\r\n\ttype State = {\r\n\t\treport: AuditReport | null;\r\n\t\tstrings: StringsMap;\r\n\t\tlocales: Locale[];\r\n\t\tselected: string | null;\r\n\t\trealKeys: Set;\r\n\t};\r\n\r\n\tconst PRIMARY_LOCALE = \"en-US\";\r\n\tconst FALLBACK_LOCALES: Locale[] = [\r\n\t\t\"da\",\r\n\t\t\"de\",\r\n\t\t\"en-GB\",\r\n\t\t\"en-US\",\r\n\t\t\"es-ES\",\r\n\t\t\"fr\",\r\n\t\t\"hr\",\r\n\t\t\"it\",\r\n\t\t\"lt\",\r\n\t\t\"hu\",\r\n\t\t\"nl\",\r\n\t\t\"no\",\r\n\t\t\"pl\",\r\n\t\t\"pt-BR\",\r\n\t\t\"ro\",\r\n\t\t\"fi\",\r\n\t\t\"sv-SE\",\r\n\t\t\"vi\",\r\n\t\t\"tr\",\r\n\t\t\"cs\",\r\n\t\t\"el\",\r\n\t\t\"bg\",\r\n\t\t\"ru\",\r\n\t\t\"uk\",\r\n\t\t\"hi\",\r\n\t\t\"th\",\r\n\t\t\"zh-CN\",\r\n\t\t\"ja\",\r\n\t\t\"zh-TW\",\r\n\t\t\"ko\",\r\n\t];\r\n\r\n\tconst storedScheme = localStorage.getItem(\"vscodeScheme\");\r\n\tlet vscodeScheme =\r\n\t\tstoredScheme === \"vscode-insiders\" || storedScheme === \"vscode\"\r\n\t\t\t? storedScheme\r\n\t\t\t: navigator.userAgent.includes(\"Insider\")\r\n\t\t\t? \"vscode-insiders\"\r\n\t\t\t: \"vscode\";\r\n\tconst REPO_BLOB_BASE =\r\n\t\t\"https://github.com/Aiko-IT-Systems/ScWikeloGrind/blob/main\";\r\n\r\n\tconst state: State = {\r\n\t\treport: null,\r\n\t\tstrings: {},\r\n\t\tlocales: [],\r\n\t\tselected: null,\r\n\t\trealKeys: new Set(),\r\n\t};\r\n\r\n\tconst el = (id: string) => document.getElementById(id)!;\r\n\tconst keyList = el(\"keyList\");\r\n\tconst details = el(\"detailsBody\");\r\n\tconst summary = el(\"summary\");\r\n\tconst scheme = el(\"scheme\") as HTMLSelectElement;\r\n\tconst search = el(\"search\") as HTMLInputElement;\r\n\tconst filter = el(\"filter\") as HTMLSelectElement;\r\n\tconst downloadBtn = el(\"downloadStrings\");\r\n\tconst modalHost = el(\"modalHost\");\r\n\r\n\tel(\"refresh\").addEventListener(\"click\", refreshAll);\r\n\tel(\"addKey\").addEventListener(\"click\", onAddKey);\r\n\tel(\"beautify\").addEventListener(\"click\", beautify);\r\n\tdownloadBtn.addEventListener(\"click\", downloadStrings);\r\n\tscheme.addEventListener(\"change\", () => {\r\n\t\tvscodeScheme = scheme.value;\r\n\t\tlocalStorage.setItem(\"vscodeScheme\", vscodeScheme);\r\n\t\trenderDetails(state.selected);\r\n\t});\r\n\r\n\tscheme.value = vscodeScheme;\r\n\tsearch.addEventListener(\"input\", renderKeyList);\r\n\tfilter.addEventListener(\"change\", renderKeyList);\r\n\r\n\tasync function loadAll() {\r\n\t\tconst [report, strings, locales] = await Promise.all([\r\n\t\t\tfetchJson(\"/api/report\"),\r\n\t\t\tfetchJson(\"/api/strings\"),\r\n\t\t\tfetchJson(\"/api/locales\"),\r\n\t\t]);\r\n\r\n\t\tstate.report = report;\r\n\t\tstate.realKeys = new Set(Object.keys(strings));\r\n\t\tstate.strings = { ...strings };\r\n\r\n\t\tconst missingKeys = report?.missingKeys ?? [];\r\n\t\tmissingKeys.forEach((key) => {\r\n\t\t\tif (!Object.prototype.hasOwnProperty.call(state.strings, key)) {\r\n\t\t\t\tstate.strings[key] = { [PRIMARY_LOCALE]: \"\" };\r\n\t\t\t}\r\n\t\t});\r\n\r\n\t\tstate.locales =\r\n\t\t\tlocales && locales.length > 0 ? locales : FALLBACK_LOCALES;\r\n\t\tsummary.textContent = `Used ${report.usedKeysCount} / Defined ${report.definedKeysCount} | Missing ${report.missingKeys.length} | Unused ${report.unusedKeys.length} | Dynamic ${report.dynamicKeyUsages.length}`;\r\n\t\trenderKeyList();\r\n\t\trenderDetails(state.selected);\r\n\t}\r\n\r\n\tfunction renderKeyList() {\r\n\t\tif (!state.report) return;\r\n\t\tconst query = search.value.toLowerCase();\r\n\t\tconst filterValue = filter.value;\r\n\t\tconst expectedLocales = collectExpectedLocales(state.strings);\r\n\r\n\t\tconst translationKeys = Object.keys(state.strings);\r\n\t\tconst missingKeys = state.report?.missingKeys ?? [];\r\n\t\tconst dynamicKeys = (state.report.dynamicKeyUsages || [])\r\n\t\t\t.map((d) => keyFromDynamic(d))\r\n\t\t\t.filter((k) => k && k.includes(\".\"));\r\n\t\tconst keys = Array.from(\r\n\t\t\tnew Set([...translationKeys, ...dynamicKeys, ...missingKeys])\r\n\t\t).sort((a, b) => a.localeCompare(b));\r\n\r\n\t\tkeyList.innerHTML = \"\";\r\n\t\tconst template = document.getElementById(\r\n\t\t\t\"key-item-template\"\r\n\t\t) as HTMLTemplateElement;\r\n\r\n\t\tkeys.forEach((key) => {\r\n\t\t\tconst translations = state.strings[key] || {};\r\n\t\t\tconst valuesText = Object.values(translations)\r\n\t\t\t\t.join(\" \")\r\n\t\t\t\t.toLowerCase();\r\n\t\t\tif (\r\n\t\t\t\tquery &&\r\n\t\t\t\t!key.toLowerCase().includes(query) &&\r\n\t\t\t\t!valuesText.includes(query)\r\n\t\t\t)\r\n\t\t\t\treturn;\r\n\r\n\t\t\tconst isReportedMissing = missingKeys.includes(key);\r\n\t\t\tconst isDynamic = state.report!.dynamicKeyUsages.some(\r\n\t\t\t\t(d) => keyFromDynamic(d) === key\r\n\t\t\t);\r\n\t\t\tconst isMissingKey = !isDynamic && (!state.realKeys.has(key) || isReportedMissing);\r\n\t\t\tconst isUnused = state.report!.unusedKeys.includes(key);\r\n\t\t\tconst primaryMissing =\r\n\t\t\t\t!translations[PRIMARY_LOCALE] ||\r\n\t\t\t\ttranslations[PRIMARY_LOCALE].trim().length === 0;\r\n\t\t\tconst isMissingLocale =\r\n\t\t\t\t!isMissingKey &&\r\n\t\t\t\t!isDynamic &&\r\n\t\t\t\t(primaryMissing || hasMissingLocale(translations, expectedLocales));\r\n\t\t\tconst isDynamicBadge = isDynamic;\r\n\r\n\t\t\tif (filterValue === \"kmissing\" && !isMissingKey) return;\r\n\t\t\tif (filterValue === \"lmissing\" && !isMissingLocale) return;\r\n\t\t\tif (filterValue === \"unused\" && !isUnused) return;\r\n\t\t\tif (filterValue === \"dynamic\" && !isDynamicBadge) return;\r\n\r\n\t\t\tconst node = template.content.firstElementChild!.cloneNode(\r\n\t\t\t\ttrue\r\n\t\t\t) as HTMLElement;\r\n\t\t\tnode.querySelector(\".key-text\")!.textContent = key;\r\n\t\t\tconst badges = node.querySelector(\".badges\")!;\r\n\t\t\tbadges.style.flexWrap = \"wrap\";\r\n\t\t\tif (isMissingKey) badges.append(makeBadge(\"Missing Key\", \"danger\"));\r\n\t\t\tif (isUnused) badges.append(makeBadge(\"Unused\", \"warn\"));\r\n\t\t\tif (!isMissingKey && isMissingLocale)\r\n\t\t\t\tbadges.append(makeBadge(\"Missing Locale\", \"danger\"));\r\n\t\t\tif (isDynamicBadge) badges.append(makeBadge(\"Dynamic\", \"success\"));\r\n\t\t\tnode.addEventListener(\"click\", () => renderDetails(key));\r\n\t\t\tif (state.selected === key) node.classList.add(\"selected\");\r\n\t\t\tkeyList.append(node);\r\n\t\t});\r\n\t}\r\n\r\n\tfunction collectExpectedLocales(strings: StringsMap) {\r\n\t\tconst set = new Set();\r\n\t\tset.add(PRIMARY_LOCALE);\r\n\t\tObject.values(strings).forEach((map) => {\r\n\t\t\tObject.keys(map || {}).forEach((loc) => set.add(loc));\r\n\t\t});\r\n\t\treturn set;\r\n\t}\r\n\r\n\tfunction hasMissingLocale(translations: TranslationMap, expected: Set) {\r\n\t\tfor (const loc of expected) {\r\n\t\t\tconst text = translations?.[loc] ?? \"\";\r\n\t\t\tif (!text || text.trim().length === 0) return true;\r\n\t\t}\r\n\t\treturn false;\r\n\t}\r\n\r\n\tfunction renderDetails(key: string | null) {\r\n\t\tstate.selected = key;\r\n\t\tif (!key) {\r\n\t\t\tdetails.className = \"details-empty\";\r\n\t\t\tdetails.textContent = \"Select a key to edit\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tconst existsInStrings = state.realKeys.has(key);\r\n\t\tconst isDynamicKey = state.report?.dynamicKeyUsages.some(\r\n\t\t\t(d) => keyFromDynamic(d) === key\r\n\t\t);\r\n\t\tconst dynamicCandidates = state.report?.dynamicKeyCandidates?.[key] ?? [];\r\n\t\tconst translations = state.strings[key] || { [PRIMARY_LOCALE]: \"\" };\r\n\t\tconst locales = orderLocales(Object.keys(translations));\r\n\r\n\t\tconst wrapper = document.createElement(\"div\");\r\n\t\twrapper.className = \"details\";\r\n\t\tconst title = document.createElement(\"div\");\r\n\t\ttitle.innerHTML = `${key}${\r\n\t\t\texistsInStrings || isDynamicKey\r\n\t\t\t\t? \"\"\r\n\t\t\t\t: ' Missing Key'\r\n\t\t}`;\r\n\t\twrapper.append(title);\r\n\t\tif (!existsInStrings && !isDynamicKey) {\r\n\t\t\tconst hint = document.createElement(\"div\");\r\n\t\t\thint.className = \"details-empty\";\r\n\t\t\thint.textContent =\r\n\t\t\t\t\"This key is missing from strings.json. Fill in values and Save to create it.\";\r\n\t\t\twrapper.append(hint);\r\n\t\t}\r\n\r\n\t\tif (isDynamicKey) {\r\n\t\t\tconst dynNote = document.createElement(\"div\");\r\n\t\t\tdynNote.className = \"details-empty\";\r\n\t\t\tdynNote.textContent = \"Dynamic key: manage concrete keys instead.\";\r\n\t\t\twrapper.append(dynNote);\r\n\t\t} else {\r\n\t\t\tconst transWrap = document.createElement(\"div\");\r\n\t\t\ttransWrap.className = \"translations\";\r\n\t\t\tlocales.forEach((locale) => {\r\n\t\t\t\ttransWrap.append(makeLocaleRow(locale, translations[locale] ?? \"\"));\r\n\t\t\t});\r\n\r\n\t\t\tconst addLocaleRow = document.createElement(\"div\");\r\n\t\t\taddLocaleRow.className = \"translation-add-row\";\r\n\t\t\tconst addSelect = document.createElement(\"select\");\r\n\t\t\tconst availableLocales = orderLocales(state.locales);\r\n\t\t\tconst existing = new Set(locales);\r\n\t\t\tconst options = availableLocales.filter((l) => !existing.has(l));\r\n\t\t\taddSelect.innerHTML = `${options\r\n\t\t\t\t.map((l) => ``)\r\n\t\t\t\t.join(\"\")}`;\r\n\t\t\tconst addButton = document.createElement(\"button\");\r\n\t\t\taddButton.textContent = \"Add locale\";\r\n\t\t\taddButton.className = \"secondary\";\r\n\t\t\taddButton.onclick = () => {\r\n\t\t\t\tconst loc = addSelect.value;\r\n\t\t\t\tif (!loc) return;\r\n\t\t\t\tif (transWrap.querySelector(`[data-locale=\"${loc}\"]`)) return;\r\n\t\t\t\ttransWrap.insertBefore(makeLocaleRow(loc, \"\"), addLocaleRow);\r\n\t\t\t\taddSelect.value = \"\";\r\n\t\t\t};\r\n\t\t\tconst actions = document.createElement(\"div\");\r\n\t\t\tactions.className = \"footer-actions\";\r\n\t\t\tconst saveBtn = document.createElement(\"button\");\r\n\t\t\tsaveBtn.textContent = \"Save\";\r\n\t\t\tsaveBtn.classList.add(\"success\");\r\n\t\t\tsaveBtn.onclick = () => saveKey(key, transWrap);\r\n\t\t\tconst deleteBtn = document.createElement(\"button\");\r\n\t\t\tdeleteBtn.textContent = \"Delete\";\r\n\t\t\tdeleteBtn.className = \"danger\";\r\n\t\t\tdeleteBtn.onclick = () => deleteKey(key);\r\n\t\t\tactions.append(saveBtn, deleteBtn);\r\n\t\t\taddLocaleRow.append(addSelect, addButton, actions);\r\n\t\t\ttransWrap.append(addLocaleRow);\r\n\t\t\twrapper.append(transWrap);\r\n\t\t}\r\n\r\n\t\tconst usageSection = document.createElement(\"div\");\r\n\t\tusageSection.className = \"usage\";\r\n\t\tusageSection.innerHTML = \"Usages\";\r\n\t\tconst usageList = document.createElement(\"div\");\r\n\t\tconst usages = state.report?.keyUsages[key] || [];\r\n\t\tconst dynUsages = (state.report?.dynamicKeyUsages || []).filter(\r\n\t\t\t(d) => keyFromDynamic(d) === key\r\n\t\t);\r\n\t\tconst usageTemplate = document.getElementById(\r\n\t\t\t\"usage-row-template\"\r\n\t\t) as HTMLTemplateElement;\r\n\t\tconst repoRoot = (state.report?.repoRoot || \"\").replace(/\\\\/g, \"/\");\r\n\t\tif (usages.length > 0) {\r\n\t\t\tusages.forEach((u) => {\r\n\t\t\t\tconst row = usageTemplate.content.firstElementChild!.cloneNode(\r\n\t\t\t\t\ttrue\r\n\t\t\t\t) as HTMLElement;\r\n\t\t\t\trow.querySelector(\r\n\t\t\t\t\t\".usage-file\"\r\n\t\t\t\t)!.textContent = `${u.file} : ${u.line}`;\r\n\t\t\t\tconst fullPath = `${repoRoot}/${u.file}`.replace(/\\\\/g, \"/\");\r\n\t\t\t\tconst openBtn = ensureUsageOpen(row);\r\n\t\t\t\topenBtn.onclick = () => {\r\n\t\t\t\t\tconst codeUrl = `${vscodeScheme}://file/${encodeURI(\r\n\t\t\t\t\t\tfullPath\r\n\t\t\t\t\t)}:${u.line}`;\r\n\t\t\t\t\twindow.open(codeUrl, \"_blank\");\r\n\t\t\t\t};\r\n\t\t\t\tconst gitBtn =\r\n\t\t\t\t\trow.querySelector(\".usage-copy\")!;\r\n\t\t\t\tconst gitUrl = `${REPO_BLOB_BASE}/${u.file.replace(\r\n\t\t\t\t\t/\\\\/g,\r\n\t\t\t\t\t\"/\"\r\n\t\t\t\t)}#L${u.line}`;\r\n\t\t\t\tgitBtn.onclick = () => copyText(gitUrl, \"Git link copied\");\r\n\t\t\t\tusageList.append(row);\r\n\t\t\t});\r\n\t\t}\r\n\t\tif (dynUsages.length > 0) {\r\n\t\t\tdynUsages.forEach((d) => {\r\n\t\t\t\tconst row = usageTemplate.content.firstElementChild!.cloneNode(\r\n\t\t\t\t\ttrue\r\n\t\t\t\t) as HTMLElement;\r\n\t\t\t\trow.querySelector(\".usage-file\")!.textContent = `${\r\n\t\t\t\t\td.file\r\n\t\t\t\t} : ${d.line} (dynamic: ${d.reason ?? \"\"})`;\r\n\t\t\t\tconst fullPath = `${repoRoot}/${d.file}`.replace(/\\\\/g, \"/\");\r\n\t\t\t\tconst openBtn = ensureUsageOpen(row);\r\n\t\t\t\topenBtn.onclick = () => {\r\n\t\t\t\t\tconst codeUrl = `${vscodeScheme}://file/${encodeURI(\r\n\t\t\t\t\t\tfullPath\r\n\t\t\t\t\t)}:${d.line}`;\r\n\t\t\t\t\twindow.open(codeUrl, \"_blank\");\r\n\t\t\t\t};\r\n\t\t\t\tconst gitBtn =\r\n\t\t\t\t\trow.querySelector(\".usage-copy\")!;\r\n\t\t\t\tconst gitUrl = `${REPO_BLOB_BASE}/${d.file.replace(\r\n\t\t\t\t\t/\\\\/g,\r\n\t\t\t\t\t\"/\"\r\n\t\t\t\t)}#L${d.line}`;\r\n\t\t\t\tgitBtn.onclick = () => copyText(gitUrl, \"Git link copied\");\r\n\t\t\t\tusageList.append(row);\r\n\t\t\t});\r\n\t\t}\r\n\t\tif (usages.length === 0 && dynUsages.length === 0) {\r\n\t\t\tconst none = document.createElement(\"div\");\r\n\t\t\tnone.textContent = \"No usages found.\";\r\n\t\t\tnone.className = \"details-empty\";\r\n\t\t\tusageList.append(none);\r\n\t\t}\r\n\t\tusageSection.append(usageList);\r\n\t\twrapper.append(usageSection);\r\n\r\n\t\tif (isDynamicKey && dynamicCandidates.length > 0) {\r\n\t\t\tconst candidateSection = document.createElement(\"div\");\r\n\t\t\tcandidateSection.className = \"usage\";\r\n\t\t\tcandidateSection.innerHTML = \"Candidate keys\";\r\n\t\t\tconst candidateList = document.createElement(\"div\");\r\n\t\t\tcandidateList.className = \"candidate-list\";\r\n\t\t\tdynamicCandidates.forEach((cand) => {\r\n\t\t\t\tconst row = document.createElement(\"div\");\r\n\t\t\t\trow.className = \"details-empty\";\r\n\t\t\t\trow.textContent = cand;\r\n\t\t\t\trow.style.cursor = \"pointer\";\r\n\t\t\t\trow.onclick = () => renderDetails(cand);\r\n\t\t\t\tcandidateList.append(row);\r\n\t\t\t});\r\n\t\t\tcandidateSection.append(candidateList);\r\n\t\t\twrapper.append(candidateSection);\r\n\t\t}\r\n\r\n\t\tdetails.className = \"\";\r\n\t\tdetails.innerHTML = \"\";\r\n\t\tdetails.append(wrapper);\r\n\t}\r\n\r\n\tfunction makeLocaleRow(locale: string, value: string) {\r\n\t\tconst row = document.createElement(\"div\");\r\n\t\trow.className = \"translation-row\";\r\n\t\trow.dataset.locale = locale;\r\n\t\tconst input = document.createElement(\"input\");\r\n\t\tinput.value = locale;\r\n\t\tinput.disabled = true;\r\n\t\tconst textarea = document.createElement(\"textarea\");\r\n\t\ttextarea.value = value ?? \"\";\r\n\t\tautosizeText(textarea);\r\n\t\ttextarea.addEventListener(\"input\", () => autosizeText(textarea));\r\n\t\tconst remove = document.createElement(\"button\");\r\n\t\tremove.textContent = \"Remove\";\r\n\t\tremove.className = \"danger\";\r\n\t\tremove.onclick = () => {\r\n\t\t\tif (locale === PRIMARY_LOCALE) return;\r\n\t\t\trow.remove();\r\n\t\t};\r\n\t\tif (locale === PRIMARY_LOCALE) {\r\n\t\t\tremove.disabled = true;\r\n\t\t\tremove.textContent = \"Primary\";\r\n\t\t}\r\n\t\trow.append(input, textarea, remove);\r\n\t\treturn row;\r\n\t}\r\n\r\n\tfunction autosizeText(textarea: HTMLTextAreaElement) {\r\n\t\ttextarea.style.height = \"auto\";\r\n\t\ttextarea.style.height = `${textarea.scrollHeight}px`;\r\n\t}\r\n\r\n\tfunction orderLocales(locales: string[]) {\r\n\t\treturn [...locales].sort((a, b) => {\r\n\t\t\tif (a === b) return 0;\r\n\t\t\tif (a === PRIMARY_LOCALE) return -1;\r\n\t\t\tif (b === PRIMARY_LOCALE) return 1;\r\n\t\t\treturn a.localeCompare(b);\r\n\t\t});\r\n\t}\r\n\r\n\tasync function saveKey(key: string, transWrap: HTMLElement) {\r\n\t\tconst ok = await confirmModal(\r\n\t\t\t\"Save changes?\",\r\n\t\t\t\"This will write updates to strings.json.\"\r\n\t\t);\r\n\t\tif (!ok) return;\r\n\t\tconst translations: TranslationMap = {};\r\n\t\ttransWrap\r\n\t\t\t.querySelectorAll(\".translation-row\")\r\n\t\t\t.forEach((row) => {\r\n\t\t\t\tconst loc = row.dataset.locale;\r\n\t\t\t\tconst text = (\r\n\t\t\t\t\trow.querySelector(\"textarea\") as HTMLTextAreaElement\r\n\t\t\t\t).value;\r\n\t\t\t\tif (loc) translations[loc] = text;\r\n\t\t\t});\r\n\t\tconst res = await fetch(`/api/strings/${encodeURIComponent(key)}`, {\r\n\t\t\tmethod: \"PUT\",\r\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\r\n\t\t\tbody: JSON.stringify({ key, translations }),\r\n\t\t});\r\n\t\tif (!res.ok) {\r\n\t\t\tconst msg = await readError(res);\r\n\t\t\talert(`Save failed: ${msg}`);\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tawait loadAll();\r\n\t\trenderDetails(key);\r\n\t\tshowToast(\"Saved\");\r\n\t}\r\n\r\n\tasync function deleteKey(key: string) {\r\n\t\tconst ok = await confirmModal(\r\n\t\t\t\"Delete key?\",\r\n\t\t\t`This removes '${key}' from strings.json.`\r\n\t\t);\r\n\t\tif (!ok) return;\r\n\t\tconst res = await fetch(`/api/strings/${encodeURIComponent(key)}`, {\r\n\t\t\tmethod: \"DELETE\",\r\n\t\t});\r\n\t\tif (!res.ok) {\r\n\t\t\tconst msg = await readError(res);\r\n\t\t\talert(`Delete failed: ${msg}`);\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tawait loadAll();\r\n\t\trenderDetails(null);\r\n\t\tshowToast(\"Deleted\");\r\n\t}\r\n\r\n\tasync function onAddKey() {\r\n\t\tconst key = prompt(\"New translation key\");\r\n\t\tif (!key) return;\r\n\t\tconst res = await fetch(\"/api/strings\", {\r\n\t\t\tmethod: \"POST\",\r\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\r\n\t\t\tbody: JSON.stringify({\r\n\t\t\t\tkey,\r\n\t\t\t\ttranslations: { [PRIMARY_LOCALE]: \"\" },\r\n\t\t\t}),\r\n\t\t});\r\n\t\tif (!res.ok) {\r\n\t\t\tconst msg = await readError(res);\r\n\t\t\talert(`Add failed: ${msg}`);\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tawait loadAll();\r\n\t\trenderDetails(key);\r\n\t}\r\n\r\n\tasync function beautify() {\r\n\t\tconst ok = await confirmModal(\r\n\t\t\t\"Beautify strings.json?\",\r\n\t\t\t\"This will rewrite strings.json with formatting.\"\r\n\t\t);\r\n\t\tif (!ok) return;\r\n\t\tconst res = await fetch(\"/api/strings/format\", { method: \"POST\" });\r\n\t\tif (!res.ok) {\r\n\t\t\tconst msg = await readError(res);\r\n\t\t\talert(`Beautify failed: ${msg}`);\r\n\t\t\treturn;\r\n\t\t}\r\n\t\tawait loadAll();\r\n\t\tshowToast(\"Formatted\");\r\n\t}\r\n\r\n\tfunction makeBadge(text: string, tone: \"warn\" | \"danger\" | \"success\") {\r\n\t\tconst span = document.createElement(\"span\");\r\n\t\tspan.className = `badge ${tone}`;\r\n\t\tspan.textContent = text;\r\n\t\treturn span;\r\n\t}\r\n\r\n\tfunction keyFromDynamic(dyn: AuditUsage | null | undefined) {\r\n\t\tif (!dyn) return \"\";\r\n\t\tconst raw = dyn.expression || \"\";\r\n\t\tconst normalized = normalizeDynamicKey(raw);\r\n\t\treturn normalized || raw || \"\";\r\n\t}\r\n\r\n\tfunction normalizeDynamicKey(expr: string) {\r\n\t\tlet s = expr.trim();\r\n\t\tif (s.startsWith(\"@$\")) s = s.slice(2);\r\n\t\telse if (s.startsWith(\"$\")) s = s.slice(1);\r\n\t\ts = s.replace(/^\"/, \"\").replace(/\"$/, \"\");\r\n\t\tconst brace = s.indexOf(\"{\");\r\n\t\tif (brace >= 0) s = s.slice(0, brace);\r\n\t\treturn s.trim();\r\n\t}\r\n\r\n\tfunction ensureUsageOpen(row: HTMLElement) {\r\n\t\tlet btn = row.querySelector(\".usage-open\");\r\n\t\tif (!btn) {\r\n\t\t\tbtn = document.createElement(\"button\");\r\n\t\t\tbtn.className = \"usage-open\";\r\n\t\t\tbtn.textContent = \"Open in VS Code\";\r\n\t\t\tconst actions =\r\n\t\t\t\trow.querySelector(\".usage-actions\") || row;\r\n\t\t\tactions.prepend(btn);\r\n\t\t}\r\n\t\treturn btn;\r\n\t}\r\n\r\n\tasync function refreshAll() {\r\n\t\ttry {\r\n\t\t\tawait loadAll();\r\n\t\t\tshowToast(\"Refreshed\");\r\n\t\t} catch (err: unknown) {\r\n\t\t\tconst message = (err as Error)?.message || String(err);\r\n\t\t\talert(`Refresh failed: ${message}`);\r\n\t\t}\r\n\t}\r\n\r\n\tasync function downloadStrings() {\r\n\t\ttry {\r\n\t\t\tconst res = await fetch(\"/api/strings\");\r\n\t\t\tif (!res.ok) throw new Error(await readError(res));\r\n\t\t\tconst data = await res.json();\r\n\t\t\tconst blob = new Blob([JSON.stringify(data, null, 2)], {\r\n\t\t\t\ttype: \"application/json\",\r\n\t\t\t});\r\n\t\t\tconst url = URL.createObjectURL(blob);\r\n\t\t\tconst a = document.createElement(\"a\");\r\n\t\t\ta.href = url;\r\n\t\t\ta.download = \"strings.json\";\r\n\t\t\ta.click();\r\n\t\t\tURL.revokeObjectURL(url);\r\n\t\t\tshowToast(\"Downloaded strings.json\");\r\n\t\t} catch (err: unknown) {\r\n\t\t\talert(`Download failed: ${err}`);\r\n\t\t}\r\n\t}\r\n\r\n\tfunction copyText(text: string, toastMessage: string) {\r\n\t\tnavigator.clipboard\r\n\t\t\t?.writeText(text)\r\n\t\t\t.then(() => showToast(toastMessage));\r\n\t}\r\n\r\n\tfunction showToast(message: string) {\r\n\t\tconst toast = document.createElement(\"div\");\r\n\t\ttoast.className = \"toast\";\r\n\t\ttoast.textContent = message;\r\n\t\tdocument.body.append(toast);\r\n\t\tsetTimeout(() => toast.remove(), 2000);\r\n\t}\r\n\r\n\tfunction confirmModal(title: string, message: string) {\r\n\t\treturn new Promise((resolve) => {\r\n\t\t\tconst backdrop = document.createElement(\"div\");\r\n\t\t\tbackdrop.className = \"modal-backdrop\";\r\n\t\t\tconst modal = document.createElement(\"div\");\r\n\t\t\tmodal.className = \"modal\";\r\n\t\t\tmodal.innerHTML = `

${title}

${message}

`;\r\n\t\t\tconst actions = document.createElement(\"div\");\r\n\t\t\tactions.className = \"modal-actions\";\r\n\t\t\tconst cancel = document.createElement(\"button\");\r\n\t\t\tcancel.className = \"ghost\";\r\n\t\t\tcancel.textContent = \"Cancel\";\r\n\t\t\tconst ok = document.createElement(\"button\");\r\n\t\t\tok.className = \"danger\";\r\n\t\t\tok.textContent = \"Confirm\";\r\n\t\t\tcancel.onclick = () => {\r\n\t\t\t\tbackdrop.remove();\r\n\t\t\t\tresolve(false);\r\n\t\t\t};\r\n\t\t\tok.onclick = () => {\r\n\t\t\t\tbackdrop.remove();\r\n\t\t\t\tresolve(true);\r\n\t\t\t};\r\n\t\t\tactions.append(cancel, ok);\r\n\t\t\tmodal.append(actions);\r\n\t\t\tbackdrop.append(modal);\r\n\t\t\tmodalHost.append(backdrop);\r\n\t\t});\r\n\t}\r\n\r\n\tasync function fetchJson(url: string) {\r\n\t\tconst res = await fetch(url);\r\n\t\tif (!res.ok) {\r\n\t\t\tconst msg = await readError(res);\r\n\t\t\tthrow new Error(msg);\r\n\t\t}\r\n\t\treturn res.json() as Promise;\r\n\t}\r\n\r\n\tasync function readError(res: Response) {\r\n\t\ttry {\r\n\t\t\tconst data = await res.json();\r\n\t\t\tif ((data as { message?: string })?.message)\r\n\t\t\t\treturn (data as { message: string }).message;\r\n\t\t\treturn `${res.status} ${res.statusText}`;\r\n\t\t} catch {\r\n\t\t\treturn `${res.status} ${res.statusText}`;\r\n\t\t}\r\n\t}\r\n\r\n\tloadAll();\r\n})();\r\n"],"mappings":"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","ignoreList":[]} \ No newline at end of file diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.ts b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.ts new file mode 100644 index 0000000..5615958 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/app.ts @@ -0,0 +1,653 @@ +(() => { + type Locale = string; + type TranslationMap = Record; + type StringsMap = Record; + + type AuditUsage = { + file: string; + line: number; + reason?: string; + expression?: string; + }; + + type AuditReport = { + usedKeysCount: number; + definedKeysCount: number; + missingKeys: string[]; + unusedKeys: string[]; + dynamicKeyUsages: AuditUsage[]; + dynamicKeyCandidates?: Record; + keyUsages: Record; + repoRoot: string; + }; + + type State = { + report: AuditReport | null; + strings: StringsMap; + locales: Locale[]; + selected: string | null; + realKeys: Set; + }; + + const PRIMARY_LOCALE = "en-US"; + const FALLBACK_LOCALES: Locale[] = [ + "da", + "de", + "en-GB", + "en-US", + "es-ES", + "fr", + "hr", + "it", + "lt", + "hu", + "nl", + "no", + "pl", + "pt-BR", + "ro", + "fi", + "sv-SE", + "vi", + "tr", + "cs", + "el", + "bg", + "ru", + "uk", + "hi", + "th", + "zh-CN", + "ja", + "zh-TW", + "ko", + ]; + + const storedScheme = localStorage.getItem("vscodeScheme"); + let vscodeScheme = + storedScheme === "vscode-insiders" || storedScheme === "vscode" + ? storedScheme + : navigator.userAgent.includes("Insider") + ? "vscode-insiders" + : "vscode"; + const REPO_BLOB_BASE = + "https://github.com/Aiko-IT-Systems/ScWikeloGrind/blob/main"; + + const state: State = { + report: null, + strings: {}, + locales: [], + selected: null, + realKeys: new Set(), + }; + + const el = (id: string) => document.getElementById(id)!; + const keyList = el("keyList"); + const details = el("detailsBody"); + const summary = el("summary"); + const scheme = el("scheme") as HTMLSelectElement; + const search = el("search") as HTMLInputElement; + const filter = el("filter") as HTMLSelectElement; + const downloadBtn = el("downloadStrings"); + const modalHost = el("modalHost"); + + el("refresh").addEventListener("click", refreshAll); + el("addKey").addEventListener("click", onAddKey); + el("beautify").addEventListener("click", beautify); + downloadBtn.addEventListener("click", downloadStrings); + scheme.addEventListener("change", () => { + vscodeScheme = scheme.value; + localStorage.setItem("vscodeScheme", vscodeScheme); + renderDetails(state.selected); + }); + + scheme.value = vscodeScheme; + search.addEventListener("input", renderKeyList); + filter.addEventListener("change", renderKeyList); + + async function loadAll() { + const [report, strings, locales] = await Promise.all([ + fetchJson("/api/report"), + fetchJson("/api/strings"), + fetchJson("/api/locales"), + ]); + + state.report = report; + state.realKeys = new Set(Object.keys(strings)); + state.strings = { ...strings }; + + const missingKeys = report?.missingKeys ?? []; + missingKeys.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(state.strings, key)) { + state.strings[key] = { [PRIMARY_LOCALE]: "" }; + } + }); + + state.locales = + locales && locales.length > 0 ? locales : FALLBACK_LOCALES; + summary.textContent = `Used ${report.usedKeysCount} / Defined ${report.definedKeysCount} | Missing ${report.missingKeys.length} | Unused ${report.unusedKeys.length} | Dynamic ${report.dynamicKeyUsages.length}`; + renderKeyList(); + renderDetails(state.selected); + } + + function renderKeyList() { + if (!state.report) return; + const query = search.value.toLowerCase(); + const filterValue = filter.value; + const expectedLocales = collectExpectedLocales(state.strings); + + const translationKeys = Object.keys(state.strings); + const missingKeys = state.report?.missingKeys ?? []; + const dynamicKeys = (state.report.dynamicKeyUsages || []) + .map((d) => keyFromDynamic(d)) + .filter((k) => k && k.includes(".")); + const keys = Array.from( + new Set([...translationKeys, ...dynamicKeys, ...missingKeys]) + ).sort((a, b) => a.localeCompare(b)); + + keyList.innerHTML = ""; + const template = document.getElementById( + "key-item-template" + ) as HTMLTemplateElement; + + keys.forEach((key) => { + const translations = state.strings[key] || {}; + const valuesText = Object.values(translations) + .join(" ") + .toLowerCase(); + if ( + query && + !key.toLowerCase().includes(query) && + !valuesText.includes(query) + ) + return; + + const isReportedMissing = missingKeys.includes(key); + const isDynamic = state.report!.dynamicKeyUsages.some( + (d) => keyFromDynamic(d) === key + ); + const isMissingKey = !isDynamic && (!state.realKeys.has(key) || isReportedMissing); + const isUnused = state.report!.unusedKeys.includes(key); + const primaryMissing = + !translations[PRIMARY_LOCALE] || + translations[PRIMARY_LOCALE].trim().length === 0; + const isMissingLocale = + !isMissingKey && + !isDynamic && + (primaryMissing || hasMissingLocale(translations, expectedLocales)); + const isDynamicBadge = isDynamic; + + if (filterValue === "kmissing" && !isMissingKey) return; + if (filterValue === "lmissing" && !isMissingLocale) return; + if (filterValue === "unused" && !isUnused) return; + if (filterValue === "dynamic" && !isDynamicBadge) return; + + const node = template.content.firstElementChild!.cloneNode( + true + ) as HTMLElement; + node.querySelector(".key-text")!.textContent = key; + const badges = node.querySelector(".badges")!; + badges.style.flexWrap = "wrap"; + if (isMissingKey) badges.append(makeBadge("Missing Key", "danger")); + if (isUnused) badges.append(makeBadge("Unused", "warn")); + if (!isMissingKey && isMissingLocale) + badges.append(makeBadge("Missing Locale", "danger")); + if (isDynamicBadge) badges.append(makeBadge("Dynamic", "success")); + node.addEventListener("click", () => renderDetails(key)); + if (state.selected === key) node.classList.add("selected"); + keyList.append(node); + }); + } + + function collectExpectedLocales(strings: StringsMap) { + const set = new Set(); + set.add(PRIMARY_LOCALE); + Object.values(strings).forEach((map) => { + Object.keys(map || {}).forEach((loc) => set.add(loc)); + }); + return set; + } + + function hasMissingLocale(translations: TranslationMap, expected: Set) { + for (const loc of expected) { + const text = translations?.[loc] ?? ""; + if (!text || text.trim().length === 0) return true; + } + return false; + } + + function renderDetails(key: string | null) { + state.selected = key; + if (!key) { + details.className = "details-empty"; + details.textContent = "Select a key to edit"; + return; + } + + const existsInStrings = state.realKeys.has(key); + const isDynamicKey = state.report?.dynamicKeyUsages.some( + (d) => keyFromDynamic(d) === key + ); + const dynamicCandidates = state.report?.dynamicKeyCandidates?.[key] ?? []; + const translations = state.strings[key] || { [PRIMARY_LOCALE]: "" }; + const locales = orderLocales(Object.keys(translations)); + + const wrapper = document.createElement("div"); + wrapper.className = "details"; + const title = document.createElement("div"); + title.innerHTML = `${key}${ + existsInStrings || isDynamicKey + ? "" + : ' Missing Key' + }`; + wrapper.append(title); + if (!existsInStrings && !isDynamicKey) { + const hint = document.createElement("div"); + hint.className = "details-empty"; + hint.textContent = + "This key is missing from strings.json. Fill in values and Save to create it."; + wrapper.append(hint); + } + + if (isDynamicKey) { + const dynNote = document.createElement("div"); + dynNote.className = "details-empty"; + dynNote.textContent = "Dynamic key: manage concrete keys instead."; + wrapper.append(dynNote); + } else { + const transWrap = document.createElement("div"); + transWrap.className = "translations"; + locales.forEach((locale) => { + transWrap.append(makeLocaleRow(locale, translations[locale] ?? "")); + }); + + const addLocaleRow = document.createElement("div"); + addLocaleRow.className = "translation-add-row"; + const addSelect = document.createElement("select"); + const availableLocales = orderLocales(state.locales); + const existing = new Set(locales); + const options = availableLocales.filter((l) => !existing.has(l)); + addSelect.innerHTML = `${options + .map((l) => ``) + .join("")}`; + const addButton = document.createElement("button"); + addButton.textContent = "Add locale"; + addButton.className = "secondary"; + addButton.onclick = () => { + const loc = addSelect.value; + if (!loc) return; + if (transWrap.querySelector(`[data-locale="${loc}"]`)) return; + transWrap.insertBefore(makeLocaleRow(loc, ""), addLocaleRow); + addSelect.value = ""; + }; + const actions = document.createElement("div"); + actions.className = "footer-actions"; + const saveBtn = document.createElement("button"); + saveBtn.textContent = "Save"; + saveBtn.classList.add("success"); + saveBtn.onclick = () => saveKey(key, transWrap); + const deleteBtn = document.createElement("button"); + deleteBtn.textContent = "Delete"; + deleteBtn.className = "danger"; + deleteBtn.onclick = () => deleteKey(key); + actions.append(saveBtn, deleteBtn); + addLocaleRow.append(addSelect, addButton, actions); + transWrap.append(addLocaleRow); + wrapper.append(transWrap); + } + + const usageSection = document.createElement("div"); + usageSection.className = "usage"; + usageSection.innerHTML = "Usages"; + const usageList = document.createElement("div"); + const usages = state.report?.keyUsages[key] || []; + const dynUsages = (state.report?.dynamicKeyUsages || []).filter( + (d) => keyFromDynamic(d) === key + ); + const usageTemplate = document.getElementById( + "usage-row-template" + ) as HTMLTemplateElement; + const repoRoot = (state.report?.repoRoot || "").replace(/\\/g, "/"); + if (usages.length > 0) { + usages.forEach((u) => { + const row = usageTemplate.content.firstElementChild!.cloneNode( + true + ) as HTMLElement; + row.querySelector( + ".usage-file" + )!.textContent = `${u.file} : ${u.line}`; + const fullPath = `${repoRoot}/${u.file}`.replace(/\\/g, "/"); + const openBtn = ensureUsageOpen(row); + openBtn.onclick = () => { + const codeUrl = `${vscodeScheme}://file/${encodeURI( + fullPath + )}:${u.line}`; + window.open(codeUrl, "_blank"); + }; + const gitBtn = + row.querySelector(".usage-copy")!; + const gitUrl = `${REPO_BLOB_BASE}/${u.file.replace( + /\\/g, + "/" + )}#L${u.line}`; + gitBtn.onclick = () => copyText(gitUrl, "Git link copied"); + usageList.append(row); + }); + } + if (dynUsages.length > 0) { + dynUsages.forEach((d) => { + const row = usageTemplate.content.firstElementChild!.cloneNode( + true + ) as HTMLElement; + row.querySelector(".usage-file")!.textContent = `${ + d.file + } : ${d.line} (dynamic: ${d.reason ?? ""})`; + const fullPath = `${repoRoot}/${d.file}`.replace(/\\/g, "/"); + const openBtn = ensureUsageOpen(row); + openBtn.onclick = () => { + const codeUrl = `${vscodeScheme}://file/${encodeURI( + fullPath + )}:${d.line}`; + window.open(codeUrl, "_blank"); + }; + const gitBtn = + row.querySelector(".usage-copy")!; + const gitUrl = `${REPO_BLOB_BASE}/${d.file.replace( + /\\/g, + "/" + )}#L${d.line}`; + gitBtn.onclick = () => copyText(gitUrl, "Git link copied"); + usageList.append(row); + }); + } + if (usages.length === 0 && dynUsages.length === 0) { + const none = document.createElement("div"); + none.textContent = "No usages found."; + none.className = "details-empty"; + usageList.append(none); + } + usageSection.append(usageList); + wrapper.append(usageSection); + + if (isDynamicKey && dynamicCandidates.length > 0) { + const candidateSection = document.createElement("div"); + candidateSection.className = "usage"; + candidateSection.innerHTML = "Candidate keys"; + const candidateList = document.createElement("div"); + candidateList.className = "candidate-list"; + dynamicCandidates.forEach((cand) => { + const row = document.createElement("div"); + row.className = "details-empty"; + row.textContent = cand; + row.style.cursor = "pointer"; + row.onclick = () => renderDetails(cand); + candidateList.append(row); + }); + candidateSection.append(candidateList); + wrapper.append(candidateSection); + } + + details.className = ""; + details.innerHTML = ""; + details.append(wrapper); + } + + function makeLocaleRow(locale: string, value: string) { + const row = document.createElement("div"); + row.className = "translation-row"; + row.dataset.locale = locale; + const input = document.createElement("input"); + input.value = locale; + input.disabled = true; + const textarea = document.createElement("textarea"); + textarea.value = value ?? ""; + autosizeText(textarea); + textarea.addEventListener("input", () => autosizeText(textarea)); + const remove = document.createElement("button"); + remove.textContent = "Remove"; + remove.className = "danger"; + remove.onclick = () => { + if (locale === PRIMARY_LOCALE) return; + row.remove(); + }; + if (locale === PRIMARY_LOCALE) { + remove.disabled = true; + remove.textContent = "Primary"; + } + row.append(input, textarea, remove); + return row; + } + + function autosizeText(textarea: HTMLTextAreaElement) { + textarea.style.height = "auto"; + textarea.style.height = `${textarea.scrollHeight}px`; + } + + function orderLocales(locales: string[]) { + return [...locales].sort((a, b) => { + if (a === b) return 0; + if (a === PRIMARY_LOCALE) return -1; + if (b === PRIMARY_LOCALE) return 1; + return a.localeCompare(b); + }); + } + + async function saveKey(key: string, transWrap: HTMLElement) { + const ok = await confirmModal( + "Save changes?", + "This will write updates to strings.json." + ); + if (!ok) return; + const translations: TranslationMap = {}; + transWrap + .querySelectorAll(".translation-row") + .forEach((row) => { + const loc = row.dataset.locale; + const text = ( + row.querySelector("textarea") as HTMLTextAreaElement + ).value; + if (loc) translations[loc] = text; + }); + const res = await fetch(`/api/strings/${encodeURIComponent(key)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, translations }), + }); + if (!res.ok) { + const msg = await readError(res); + alert(`Save failed: ${msg}`); + return; + } + await loadAll(); + renderDetails(key); + showToast("Saved"); + } + + async function deleteKey(key: string) { + const ok = await confirmModal( + "Delete key?", + `This removes '${key}' from strings.json.` + ); + if (!ok) return; + const res = await fetch(`/api/strings/${encodeURIComponent(key)}`, { + method: "DELETE", + }); + if (!res.ok) { + const msg = await readError(res); + alert(`Delete failed: ${msg}`); + return; + } + await loadAll(); + renderDetails(null); + showToast("Deleted"); + } + + async function onAddKey() { + const key = prompt("New translation key"); + if (!key) return; + const res = await fetch("/api/strings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key, + translations: { [PRIMARY_LOCALE]: "" }, + }), + }); + if (!res.ok) { + const msg = await readError(res); + alert(`Add failed: ${msg}`); + return; + } + await loadAll(); + renderDetails(key); + } + + async function beautify() { + const ok = await confirmModal( + "Beautify strings.json?", + "This will rewrite strings.json with formatting." + ); + if (!ok) return; + const res = await fetch("/api/strings/format", { method: "POST" }); + if (!res.ok) { + const msg = await readError(res); + alert(`Beautify failed: ${msg}`); + return; + } + await loadAll(); + showToast("Formatted"); + } + + function makeBadge(text: string, tone: "warn" | "danger" | "success") { + const span = document.createElement("span"); + span.className = `badge ${tone}`; + span.textContent = text; + return span; + } + + function keyFromDynamic(dyn: AuditUsage | null | undefined) { + if (!dyn) return ""; + const raw = dyn.expression || ""; + const normalized = normalizeDynamicKey(raw); + return normalized || raw || ""; + } + + function normalizeDynamicKey(expr: string) { + let s = expr.trim(); + if (s.startsWith("@$")) s = s.slice(2); + else if (s.startsWith("$")) s = s.slice(1); + s = s.replace(/^"/, "").replace(/"$/, ""); + const brace = s.indexOf("{"); + if (brace >= 0) s = s.slice(0, brace); + return s.trim(); + } + + function ensureUsageOpen(row: HTMLElement) { + let btn = row.querySelector(".usage-open"); + if (!btn) { + btn = document.createElement("button"); + btn.className = "usage-open"; + btn.textContent = "Open in VS Code"; + const actions = + row.querySelector(".usage-actions") || row; + actions.prepend(btn); + } + return btn; + } + + async function refreshAll() { + try { + await loadAll(); + showToast("Refreshed"); + } catch (err: unknown) { + const message = (err as Error)?.message || String(err); + alert(`Refresh failed: ${message}`); + } + } + + async function downloadStrings() { + try { + const res = await fetch("/api/strings"); + if (!res.ok) throw new Error(await readError(res)); + const data = await res.json(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "strings.json"; + a.click(); + URL.revokeObjectURL(url); + showToast("Downloaded strings.json"); + } catch (err: unknown) { + alert(`Download failed: ${err}`); + } + } + + function copyText(text: string, toastMessage: string) { + navigator.clipboard + ?.writeText(text) + .then(() => showToast(toastMessage)); + } + + function showToast(message: string) { + const toast = document.createElement("div"); + toast.className = "toast"; + toast.textContent = message; + document.body.append(toast); + setTimeout(() => toast.remove(), 2000); + } + + function confirmModal(title: string, message: string) { + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "modal"; + modal.innerHTML = `

${title}

${message}

`; + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancel = document.createElement("button"); + cancel.className = "ghost"; + cancel.textContent = "Cancel"; + const ok = document.createElement("button"); + ok.className = "danger"; + ok.textContent = "Confirm"; + cancel.onclick = () => { + backdrop.remove(); + resolve(false); + }; + ok.onclick = () => { + backdrop.remove(); + resolve(true); + }; + actions.append(cancel, ok); + modal.append(actions); + backdrop.append(modal); + modalHost.append(backdrop); + }); + } + + async function fetchJson(url: string) { + const res = await fetch(url); + if (!res.ok) { + const msg = await readError(res); + throw new Error(msg); + } + return res.json() as Promise; + } + + async function readError(res: Response) { + try { + const data = await res.json(); + if ((data as { message?: string })?.message) + return (data as { message: string }).message; + return `${res.status} ${res.statusText}`; + } catch { + return `${res.status} ${res.statusText}`; + } + } + + loadAll(); +})(); diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/favicon.ico b/DisCatSharp.Extensions.Translations.Manager/wwwroot/favicon.ico new file mode 100644 index 0000000..45800b2 Binary files /dev/null and b/DisCatSharp.Extensions.Translations.Manager/wwwroot/favicon.ico differ diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/index.html b/DisCatSharp.Extensions.Translations.Manager/wwwroot/index.html new file mode 100644 index 0000000..11d4406 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/index.html @@ -0,0 +1,67 @@ + + + + + + + Translation Tool + + + + + +
+
Translation Tool
+
+ + + + + +
+ +
+
+
+ + +
+
+
+ +
+
Select a key to edit
+
+
+ + + + + +
+ + + + + diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/jsconfig.json b/DisCatSharp.Extensions.Translations.Manager/wwwroot/jsconfig.json new file mode 100644 index 0000000..6cace4e --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "checkJs": false + }, + "exclude": [ + "app.js", + "app.js.map" + ] +} diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/styles.css b/DisCatSharp.Extensions.Translations.Manager/wwwroot/styles.css new file mode 100644 index 0000000..1a09926 --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/styles.css @@ -0,0 +1,359 @@ +* { + box-sizing: border-box; +} + +:root { + --bg: #0f172a; + --panel: #111827; + --border: #1f2937; + --text: #e5e7eb; + --muted: #9ca3af; + --accent: #22d3ee; + --warn: #fbbf24; + --danger: #f87171; + --success: #34d399; + font-family: "Segoe UI", sans-serif; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: #0b1220; +} + +.title { + font-weight: 700; + letter-spacing: 0.5px; +} + +.summary { + color: var(--muted); + flex: 1; + font-size: 14px; +} + +button { + background: var(--accent); + color: #0b1220; + border: none; + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; + font-weight: 600; +} + +button.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +button.danger { + background: var(--danger); + color: #0b1220; +} + +button.warn { + background: #fbbf24; + color: #0b1220; +} + +button.success { + background: var(--success); + color: #0b1220; +} + +button.warn { + background: #fbbf24; + color: #0b1220; +} + +button[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +button.secondary { + background: var(--border); + color: var(--text); +} + +input, +select, +textarea { + background: #0b1220; + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; +} + +.layout { + display: grid; + grid-template-columns: 380px 1fr; + gap: 12px; + padding: 12px; + height: calc(100vh - 64px); +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +#search { + flex: 1; +} + +.list { + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 8px; + flex: 1; +} + +.key-item { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.key-item:hover { + background: #0c182c; +} + +.key-item.selected { + background: #0c182c; + border-left: 2px solid var(--accent); +} + +.key-text { + font-size: 14px; + word-break: break-all; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--border); + color: var(--muted); +} + +.badge.warn { + color: var(--warn); + border-color: var(--warn); +} + +.badge.danger { + color: var(--danger); + border-color: var(--danger); +} + +.badge.success { + color: var(--success); + border-color: var(--success); +} + +.details-empty { + color: var(--muted); +} + +.details { + display: flex; + flex-direction: column; + gap: 12px; + overflow: auto; +} + +.translations { + display: flex; + flex-direction: column; + gap: 8px; +} + +.translation-row { + display: flex; + gap: 8px; + align-items: center; +} + +.translation-row input { + flex: 0 0 120px; +} + +.translation-row textarea { + flex: 1; + min-height: 60px; +} + +.translation-row button { + flex: 0 0 auto; +} + +.translation-add-row { + display: flex; + gap: 8px; + align-items: center; +} + +.usage { + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.usage-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 13px; +} + +.usage-actions { + display: flex; + gap: 6px; +} + +.usage-open { + background: var(--accent); + color: #0b1220; + border: none; +} + +.usage-copy { + background: var(--warn); + color: #0b1220; + border: none; +} + +#downloadStrings { + background: var(--success); + color: #0b1220; + border: none; +} + +#refresh { + background: var(--accent); + color: #0b1220; + border: none; +} + +.footer-actions { + display: flex; + gap: 8px; +} + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + max-width: 420px; + width: 90%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); +} + +.modal h3 { + margin: 0 0 8px 0; +} + +.modal p { + margin: 0 0 16px 0; + color: var(--muted); +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.toast { + position: fixed; + bottom: 16px; + right: 16px; + background: var(--panel); + border: 1px solid var(--success); + color: var(--text); + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: translateY(10px); + animation: toast-in 200ms ease-out forwards, + toast-out 200ms ease-in forwards 1600ms; + z-index: 1100; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toast-out { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(10px); + } +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } +} diff --git a/DisCatSharp.Extensions.Translations.Manager/wwwroot/tsconfig.json b/DisCatSharp.Extensions.Translations.Manager/wwwroot/tsconfig.json new file mode 100644 index 0000000..5aafd6d --- /dev/null +++ b/DisCatSharp.Extensions.Translations.Manager/wwwroot/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "none", + "outFile": "app.js", + "lib": ["DOM", "ES2020"], + "strict": true, + "noEmitOnError": true, + "moduleResolution": "classic", + "allowJs": false, + "removeComments": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "inlineSources": true, + "sourceRoot": "http://localhost:5000/" + }, + "files": ["app.ts"] +} diff --git a/DisCatSharp.Extensions.Translations/DisCatSharp.Extensions.Translations.csproj b/DisCatSharp.Extensions.Translations/DisCatSharp.Extensions.Translations.csproj new file mode 100644 index 0000000..4bebcb9 --- /dev/null +++ b/DisCatSharp.Extensions.Translations/DisCatSharp.Extensions.Translations.csproj @@ -0,0 +1,47 @@ + + + + + + + + + + + + + DisCatSharp.Extensions.Translations + DisCatSharp.Extensions.Translations + + + + DisCatSharp.Extensions.Translations + + DisCatSharp.Extensions.Translations + + Core extension for DisCatSharp making it easy to use localizations in your bot. + + DisCatSharp,DisCatSharp Extension,Translations,Localization,Discord,Bots,Discord Bots,AITSYS,Net8,Net9,Net10 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/DisCatSharp.Extensions.Translations/ExtensionMethods.cs b/DisCatSharp.Extensions.Translations/ExtensionMethods.cs new file mode 100644 index 0000000..147539f --- /dev/null +++ b/DisCatSharp.Extensions.Translations/ExtensionMethods.cs @@ -0,0 +1,117 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; + +using DisCatSharp.Entities; + +namespace DisCatSharp.Extensions.Translations; + +/// +/// Defines various extensions specific to TranslationsExtension. +/// +public static class ExtensionMethods +{ + /// + /// Enables TranslationsExtension module on this . + /// + /// Client to enable TranslationsExtension for. + /// Lavalink configuration to use. + /// Created . + public static TranslationsExtension UseTranslations(this DiscordClient client, TranslationsConfiguration? cfg = null) + { + if (client.GetExtension() != null) + throw new InvalidOperationException("TranslationsExtension is already enabled for that client."); + + cfg ??= new(); + + var smc = new TranslationsExtension(cfg); + client.AddExtension(smc); + return smc; + } + + /// + /// Enables TranslationsExtension module on all shards in this . + /// + /// Client to enable TranslationsExtension for. + /// Lavalink configuration to use. + /// A dictionary of created , indexed by shard id. + public static async Task> UseTranslationsAsync(this DiscordShardedClient client, TranslationsConfiguration? cfg = null) + { + var modules = new Dictionary(); + await client.InitializeShardsAsync().ConfigureAwait(false); + + foreach (var shard in client.ShardClients.Select(xkvp => xkvp.Value)) + { + var smc = shard.GetExtension(); + smc ??= shard.UseTranslations(cfg); + modules[shard.ShardId] = smc; + } + + return new ReadOnlyDictionary(modules); + } + + /// + /// Gets the active TranslationsExtension module for this client. + /// + /// Client to get TranslationsExtension module from. + /// The module, or null if not activated. + public static TranslationsExtension? GetTranslationsExtension(this DiscordClient client) + => client.GetExtension(); + + /// + /// Gets the active TranslationsExtension modules for all shards in this client. + /// + /// Client to get TranslationsExtension instances from. + /// A dictionary of the modules, indexed by shard id. + public static async Task> GetTranslationsExtension(this DiscordShardedClient client) + { + await client.InitializeShardsAsync().ConfigureAwait(false); + var extensions = client.ShardClients.Select(xkvp => xkvp.Value).ToDictionary(shard => shard.ShardId, shard => shard.GetExtension()); + + return new ReadOnlyDictionary(extensions); + } + + /// + /// Translates a key using the locale from the . + /// + /// The interaction. + /// The translation key. + /// The placeholders to replace in the translation. + /// Whether to force the guild locale. + /// The translated string. + public static string T(this DiscordInteraction interaction, string key, object? placeholders = null, bool forceGuildLocale = false) + { + var locale = "en-US"; + if (forceGuildLocale && !string.IsNullOrWhiteSpace(interaction.GuildLocale)) + locale = interaction.GuildLocale; + else if (!string.IsNullOrWhiteSpace(interaction.Locale)) + locale = interaction.Locale; + else if (!string.IsNullOrWhiteSpace(interaction.GuildLocale)) + locale = interaction.GuildLocale; + return (interaction.Discord as DiscordClient)!.GetTranslationsExtension()!.TranslationEngine.TLocale(locale, key, placeholders); + } +} diff --git a/DisCatSharp.Extensions.Translations/InternalsVisibleTo.targets b/DisCatSharp.Extensions.Translations/InternalsVisibleTo.targets new file mode 100644 index 0000000..9466261 --- /dev/null +++ b/DisCatSharp.Extensions.Translations/InternalsVisibleTo.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/DisCatSharp.Extensions.Translations/TranslationEngine.cs b/DisCatSharp.Extensions.Translations/TranslationEngine.cs new file mode 100644 index 0000000..969333c --- /dev/null +++ b/DisCatSharp.Extensions.Translations/TranslationEngine.cs @@ -0,0 +1,211 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System; +using System.Collections.Concurrent; + +namespace DisCatSharp.Extensions.Translations; + +/// +/// Represents a translation engine. +/// +/// The translations configuration. +public partial class TranslationEngine(TranslationsConfiguration configuration) +{ + /// + /// Guard for initialization. + /// + private readonly Lock _initLock = new(); + + /// + /// Whether the translations have been initialized. + /// + private volatile bool _initialized; + + /// + /// Provides a mapping of translation keys to localized string values for multiple languages. + /// + /// Each entry in the outer dictionary represents a language, identified by its language code (such as + /// "en" or "fr"). The inner dictionary maps translation keys to their corresponding localized strings for that + /// language. + internal ConcurrentDictionary> Translations { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the default locale. + /// + internal string DefaultLocale { get; } = configuration.DefaultLocale ?? "en-US"; + + /// + /// Gets or sets the path to the translations file. + /// + internal string? PATH { get; set; } + + /// + /// Gets the configuration. + /// + internal readonly TranslationsConfiguration Configuration = configuration; + + /// + /// Ensures that the translations are loaded from the file. + /// + private void EnsureLoaded() + { + if (this._initialized) + return; + lock (this._initLock) + { + if (this._initialized) + return; + if (this.PATH is null) + return; + try + { + if (!File.Exists(this.PATH)) + { + this.Translations = new(StringComparer.OrdinalIgnoreCase); + this._initialized = true; + return; + } + + var json = File.ReadAllText(this.PATH); + var doc = JsonSerializer.Deserialize>>(json) ?? []; + this.Translations = new(doc, StringComparer.OrdinalIgnoreCase); + } + catch + { + this.Translations = new(StringComparer.OrdinalIgnoreCase); + } + finally + { + this._initialized = true; + } + } + } + + /// + /// Translates a key using the specified locale. + /// + /// The locale to use for translation. + /// The translation key. + /// The placeholders to replace in the translation. + /// The translated string. + public string TLocale(string? locale, string key, object? placeholders = null) + { + this.EnsureLoaded(); + locale ??= "en-US"; + return !this.Translations.TryGetValue(key, out var langs) + ? this.ApplyPlaceholders(key, placeholders) + : langs.TryGetValue(locale, out var text) + ? this.ApplyPlaceholders(text, placeholders) + : langs.TryGetValue("en-US", out var fallback) + ? this.ApplyPlaceholders(fallback, placeholders) + : this.ApplyPlaceholders(key, placeholders); + } + + /// + /// Applies placeholders to the template string. + /// + /// The template string. + /// The placeholders to replace in the template. + /// The template string with placeholders replaced. + private string ApplyPlaceholders(string template, object? placeholders) + { + if (placeholders is null) + return template; + + if (placeholders is not Dictionary values) + { + values = new(StringComparer.OrdinalIgnoreCase); + var props = placeholders.GetType().GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + foreach (var p in props) + { + try + { + values[p.Name] = p.GetValue(placeholders); + } + catch + { + values[p.Name] = null; + } + } + } + + var result = this.TemplateRegex().Replace(template, m => + { + var name = m.Groups[1].Value; + return values.TryGetValue(name, out var val) && val is not null ? val.ToString()! : string.Empty; + }); + + return result; + } + + /// + /// Reloads the translations from the file. + /// + public void Reload() + { + lock (this._initLock) + { + this._initialized = false; + this.EnsureLoaded(); + } + } + + /// + /// Allows overriding the file path used for translations and loads from it immediately. + /// + public void UseFile(string filePath) + { + lock (this._initLock) + { + this.PATH = filePath; + this._initialized = false; + this.EnsureLoaded(); + } + } + + /// + /// Template regex for matching placeholders. + /// + [GeneratedRegex(@"\{\s*([a-zA-Z0-9_]+)\s*\}", RegexOptions.Compiled)] + private partial Regex TemplateRegex(); + + /// + /// Initializes the translations engine. + /// + internal void Initialize() + { + if (!string.IsNullOrWhiteSpace(this.Configuration.TranslationsFolder) && !string.IsNullOrWhiteSpace(this.Configuration.TranslationsFileName)) + { + var path = Path.Combine(this.Configuration.TranslationsFolder, this.Configuration.TranslationsFileName); + if (File.Exists(path)) + this.UseFile(path); + else + throw new FileNotFoundException("Translations file not found at the specified path.", path); + } + } +} diff --git a/DisCatSharp.Extensions.Translations/TranslationsConfiguration.cs b/DisCatSharp.Extensions.Translations/TranslationsConfiguration.cs new file mode 100644 index 0000000..8676373 --- /dev/null +++ b/DisCatSharp.Extensions.Translations/TranslationsConfiguration.cs @@ -0,0 +1,87 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; + +using Microsoft.Extensions.DependencyInjection; + +namespace DisCatSharp.Extensions.Translations; + +/// +/// Represents a configuration for . +/// +public class TranslationsConfiguration +{ + /// + /// Creates a new instance of . + /// + [ActivatorUtilitiesConstructor] + public TranslationsConfiguration() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + [ActivatorUtilitiesConstructor] + public TranslationsConfiguration(IServiceProvider provider) + { + this.ServiceProvider = provider; + } + + /// + /// Creates a new instance of , copying the properties of another configuration. + /// + /// Configuration the properties of which are to be copied. + public TranslationsConfiguration(TranslationsConfiguration other) + { + this.TranslationsFolder = other.TranslationsFolder; + this.TranslationsFileName = other.TranslationsFileName; + this.ServiceProvider = other.ServiceProvider; + this.DefaultLocale = other.DefaultLocale; + } + + /// + /// Sets the service provider for this Translations instance. + /// + /// Objects in this provider are used when instantiating command modules. This allows passing data around without + /// resorting to static members. + /// + /// Defaults to an empty service provider. + /// + public IServiceProvider ServiceProvider { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); + + /// + /// Sets the folder where translation files are stored. Defaults to data/translations. + /// + public string TranslationsFolder { internal get; set; } = "data/translations"; + + /// + /// Sets the name of the translations file. Defaults to strings.json. + /// + public string TranslationsFileName { internal get; set; } = "strings.json"; + + /// + /// Gets or sets the default locale used for culture-specific operations. + /// + public string DefaultLocale { internal get; set; } = "en-US"; +} diff --git a/DisCatSharp.Extensions.Translations/TranslationsExtension.cs b/DisCatSharp.Extensions.Translations/TranslationsExtension.cs new file mode 100644 index 0000000..4bc423f --- /dev/null +++ b/DisCatSharp.Extensions.Translations/TranslationsExtension.cs @@ -0,0 +1,89 @@ +// This file is part of the DisCatSharp project. +// +// Copyright (c) 2021-2025 AITSYS +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Reflection; + +namespace DisCatSharp.Extensions.Translations; + +/// +/// Represents a . +/// +public sealed class TranslationsExtension : BaseExtension +{ + /// + /// Initializes a new instance of the class. + /// + /// The config. + internal TranslationsExtension(TranslationsConfiguration? configuration = null) + { + configuration ??= new(); + this.TranslationEngine = new(configuration); + } + + /// + /// Gets the translation engine. + /// + public TranslationEngine TranslationEngine { get; private set; } + + /// + /// Gets the discord client. + /// + internal new DiscordClient Client { get; private set; } + + /// + /// DO NOT USE THIS MANUALLY. + /// + /// DO NOT USE THIS MANUALLY. + /// + protected internal override void Setup(DiscordClient client) + { + if (this.Client != null) + throw new InvalidOperationException("What did I tell you?"); + + this.Client = client; + + var a = typeof(TranslationsExtension).GetTypeInfo().Assembly; + + var iv = a.GetCustomAttribute(); + if (iv != null) + this.VersionString = iv.InformationalVersion; + else + { + var v = a.GetName().Version; + var vs = v!.ToString(3); + + if (v.Revision > 0) + this.VersionString = $"{vs}, CI build {v.Revision}"; + } + + this.HasVersionCheckSupport = true; + + this.RepositoryOwner = "Aiko-IT-Systems"; + + this.Repository = "DisCatSharp.Extensions"; + + this.PackageId = "DisCatSharp.Extensions.Translations"; + + this.TranslationEngine.Initialize(); + } +} diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Entities/TwoFactorResponse.cs b/DisCatSharp.Extensions.TwoFactorCommands/Entities/TwoFactorResponse.cs index 6c7204b..044de15 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Entities/TwoFactorResponse.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Entities/TwoFactorResponse.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Enums/TwoFactorResult.cs b/DisCatSharp.Extensions.TwoFactorCommands/Enums/TwoFactorResult.cs index 24bc721..bbcfa1f 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Enums/TwoFactorResult.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Enums/TwoFactorResult.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/ExtensionMethods.cs b/DisCatSharp.Extensions.TwoFactorCommands/ExtensionMethods.cs index a9c7849..e9d465c 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/ExtensionMethods.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/ExtensionMethods.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/ApplicationCommandRequireEnrolledTwoFactorAttribute.cs b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/ApplicationCommandRequireEnrolledTwoFactorAttribute.cs index f8db251..8303a60 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/ApplicationCommandRequireEnrolledTwoFactorAttribute.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/ApplicationCommandRequireEnrolledTwoFactorAttribute.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/TwoFactorApplicationCommandExtension.cs b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/TwoFactorApplicationCommandExtension.cs index a2c1bd5..0edb856 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/TwoFactorApplicationCommandExtension.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/ApplicationCommands/TwoFactorApplicationCommandExtension.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/CommandRequireEnrolledTwoFactorAttribute.cs b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/CommandRequireEnrolledTwoFactorAttribute.cs index 806ef98..1e55eeb 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/CommandRequireEnrolledTwoFactorAttribute.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/CommandRequireEnrolledTwoFactorAttribute.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/TwoFactorCommandsNextExtension.cs b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/TwoFactorCommandsNextExtension.cs index ccb33b2..c6031b4 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/TwoFactorCommandsNextExtension.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Extensions/CommandsNext/TwoFactorCommandsNextExtension.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/Properties/AssemblyProperties.cs b/DisCatSharp.Extensions.TwoFactorCommands/Properties/AssemblyProperties.cs index f91d345..81ecc1e 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/Properties/AssemblyProperties.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/Properties/AssemblyProperties.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorConfiguration.cs b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorConfiguration.cs index f753141..38a5368 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorConfiguration.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorConfiguration.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtension.cs b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtension.cs index 7388cb8..b9ca53c 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtension.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtension.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtensionUtilities.cs b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtensionUtilities.cs index adad3da..e5d3e39 100644 --- a/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtensionUtilities.cs +++ b/DisCatSharp.Extensions.TwoFactorCommands/TwoFactorExtensionUtilities.cs @@ -1,6 +1,6 @@ // This file is part of the DisCatSharp project. // -// Copyright (c) 2021-2023 AITSYS +// Copyright (c) 2021-2025 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/DisCatSharp.Extensions.slnx b/DisCatSharp.Extensions.slnx index 52f089a..ebf1479 100644 --- a/DisCatSharp.Extensions.slnx +++ b/DisCatSharp.Extensions.slnx @@ -35,4 +35,9 @@ + + + + + diff --git a/README.md b/README.md index 7799631..a96e463 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # DisCatSharp.Extensions -Official extensions for [DisCatSharp](https://github.com/Aiko-IT-Systems/DisCatSharp). +Official extensions for [DisCatSharp](https://github.com/Aiko-IT-Systems/DisCatSharp). ## Installing @@ -21,8 +21,11 @@ The documentation is available at [ext-docs.dcs.aitsys.dev](https://ext-docs.dcs ## NuGet Packages -| Package | LTS | Stable | -| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| DisCatSharp.Extensions.TwoFactorCommands | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.TwoFactorCommands.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.TwoFactorCommands.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | -| DisCatSharp.Extensions.OAuth2Web | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.OAuth2Web.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.OAuth2Web.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | -| DisCatSharp.Extensions.SimpleMusicCommands | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.SimpleMusicCommands.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.SimpleMusicCommands.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| Package | LTS | Stable | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DisCatSharp.Extensions.TwoFactorCommands | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.TwoFactorCommands.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.TwoFactorCommands.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| DisCatSharp.Extensions.OAuth2Web | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.OAuth2Web.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.OAuth2Web.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| DisCatSharp.Extensions.SimpleMusicCommands | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.SimpleMusicCommands.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.SimpleMusicCommands.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| DisCatSharp.Extensions.Translations | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.Translations.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.Translations.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| DisCatSharp.Extensions.Translations.ApplicationCommands | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.Translations.ApplicationCommands.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.Translations.ApplicationCommands.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | +| DisCatSharp.Extensions.Translations.CommandsNext | ![NuGet](https://img.shields.io/nuget/v/DisCatSharp.Extensions.Translations.CommandsNext.svg?label=&logo=nuget&style=flat-square) | ![NuGet](https://img.shields.io/nuget/vpre/DisCatSharp.Extensions.Translations.CommandsNext.svg?label=&logo=nuget&style=flat-square&color=%23ff1493) | diff --git a/Targets/Library.targets b/Targets/Library.targets index 7935a82..2cc312f 100644 --- a/Targets/Library.targets +++ b/Targets/Library.targets @@ -6,5 +6,6 @@ net8.0;net9.0;net10.0 enable latest + enable - + \ No newline at end of file