diff --git a/Flow.Launcher.Localization/.github/workflows/build.yml b/Flow.Launcher.Localization/.github/workflows/build.yml new file mode 100644 index 00000000000..d23ee423d5d --- /dev/null +++ b/Flow.Launcher.Localization/.github/workflows/build.yml @@ -0,0 +1,54 @@ +name: build + +on: + workflow_dispatch: + push: + branches: [ "main" ] + pull_request: + +jobs: + + build: + + runs-on: windows-latest + + env: + Dotnet_Version: 8.0.x + Project_Path: Flow.Launcher.Localization\Flow.Launcher.Localization.csproj + + steps: + + # Checkout codes + - name: Checkout + uses: actions/checkout@v4 + + # Install the .NET Core workload + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.Dotnet_Version }} + + # Restore dependencies + - name: Restore dependencies + run: dotnet restore ${{ env.Project_Path }} + + # Build the project + - name: Build + run: dotnet build ${{ env.Project_Path }} --configuration Release --no-restore + + # Execute all unit tests in the solution + - name: Execute unit tests + if: github.event_name == 'push' && github.ref != 'refs/heads/main' + run: dotnet test --configuration Release --no-build + + # Pack the NuGet package + - name: Create NuGet package + run: dotnet pack ${{ env.Project_Path }} --configuration Release --no-build --output nupkgs + + # Upload the NuGet package + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: Full nupkg + path: nupkgs/Flow.Launcher.Localization.*.nupkg + compression-level: 0 diff --git a/Flow.Launcher.Localization/.github/workflows/pr_assignee.yml b/Flow.Launcher.Localization/.github/workflows/pr_assignee.yml new file mode 100644 index 00000000000..368932293d2 --- /dev/null +++ b/Flow.Launcher.Localization/.github/workflows/pr_assignee.yml @@ -0,0 +1,15 @@ +name: Assign PR to creator + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + +jobs: + automation: + runs-on: ubuntu-latest + steps: + - name: Assign PR to creator + uses: toshimaru/auto-author-assign@v2.1.1 diff --git a/Flow.Launcher.Localization/.github/workflows/publish.yml b/Flow.Launcher.Localization/.github/workflows/publish.yml new file mode 100644 index 00000000000..70c7809dd77 --- /dev/null +++ b/Flow.Launcher.Localization/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +name: publish + +on: + workflow_dispatch: + push: + tags: + - '*' + +jobs: + + publish: + + runs-on: windows-latest + + env: + Dotnet_Version: 8.0.x + Project_Path: Flow.Launcher.Localization\Flow.Launcher.Localization.csproj + + steps: + + # Checkout codes + - name: Checkout + uses: actions/checkout@v4 + + # Install the .NET Core workload + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.Dotnet_Version }} + + # Restore dependencies + - name: Restore dependencies + run: dotnet restore ${{ env.Project_Path }} + + # Build the project + - name: Build + run: dotnet build ${{ env.Project_Path }} --configuration Release --no-restore + + # Pack the NuGet package + - name: Create NuGet package + run: dotnet pack ${{ env.Project_Path }} --configuration Release --no-build --output nupkgs + + # Publish to NuGet.org + - name: Push to NuGet + # if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: nuget push nupkgs\*.nupkg -source 'https://api.nuget.org/v3/index.json' -apikey ${{ secrets.NUGET_API_KEY }} + + # Get package version + - name: Get Package Version + # if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + $version = [system.diagnostics.fileversioninfo]::getversioninfo("Flow.Launcher.Localization\bin\Release\netstandard2.0\Flow.Launcher.Localization.dll").fileversion + echo "release_version=$version" | out-file -filepath $env:github_env -encoding utf-8 -append + + # Publish to GitHub releases + - name: Publish GitHub releases + # if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: softprops/action-gh-release@v1 + with: + files: "nupkgs/*.nupkg" + tag_name: "v${{ env.release_version }}" diff --git a/Flow.Launcher.Localization/.gitignore b/Flow.Launcher.Localization/.gitignore new file mode 100644 index 00000000000..a5e6adf01a3 --- /dev/null +++ b/Flow.Launcher.Localization/.gitignore @@ -0,0 +1,7 @@ +.idea/ +.vs/ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerDiagnostics.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerDiagnostics.cs new file mode 100644 index 00000000000..83174541351 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerDiagnostics.cs @@ -0,0 +1,53 @@ +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; + +namespace Flow.Launcher.Localization.Analyzers +{ + public static class AnalyzerDiagnostics + { + public static readonly DiagnosticDescriptor OldLocalizationApiUsed = new DiagnosticDescriptor( + "FLAN0001", + "Old localization API used", + $"Use `{Constants.ClassName}.{{0}}({{1}})` instead", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextIsAField = new DiagnosticDescriptor( + "FLAN0002", + "Plugin context is a field", + "Plugin context must be at least internal static property", + "Localization", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextIsNotStatic = new DiagnosticDescriptor( + "FLAN0003", + "Plugin context is not static", + "Plugin context must be at least internal static property", + "Localization", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextAccessIsTooRestrictive = new DiagnosticDescriptor( + "FLAN0004", + "Plugin context property access modifier is too restrictive", + "Plugin context property must be at least internal static property", + "Localization", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextIsNotDeclared = new DiagnosticDescriptor( + "FLAN0005", + "Plugin context is not declared", + $"Plugin context must be at least internal static property of type `{Constants.PluginContextTypeName}`", + "Localization", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Shipped.md b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000000..5ccc9f037f6 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Unshipped.md b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000000..d5f177c6c1a --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +FLAN0001 | Localization | Warning | FLAN0001_OldLocalizationApiUsed +FLAN0002 | Localization | Error | FLAN0002_ContextIsAField +FLAN0003 | Localization | Error | FLAN0003_ContextIsNotStatic +FLAN0004 | Localization | Error | FLAN0004_ContextAccessIsTooRestrictive +FLAN0005 | Localization | Error | FLAN0005_ContextIsNotDeclared diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Flow.Launcher.Localization.Analyzers.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Flow.Launcher.Localization.Analyzers.csproj new file mode 100644 index 00000000000..b70b72e5b9b --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Flow.Launcher.Localization.Analyzers.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + true + Flow.Launcher.Localization.Analyzers + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzer.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzer.cs new file mode 100644 index 00000000000..fa9da7604a2 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzer.cs @@ -0,0 +1,106 @@ +using System.Collections.Immutable; +using System.Linq; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Flow.Launcher.Localization.Analyzers.Localize +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ContextAvailabilityAnalyzer : DiagnosticAnalyzer + { + #region DiagnosticAnalyzer + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + AnalyzerDiagnostics.ContextIsAField, + AnalyzerDiagnostics.ContextIsNotStatic, + AnalyzerDiagnostics.ContextAccessIsTooRestrictive, + AnalyzerDiagnostics.ContextIsNotDeclared + ); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.ClassDeclaration); + } + + #endregion + + #region Analyze Methods + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + var configOptions = context.Options.AnalyzerConfigOptionsProvider; + var useDI = configOptions.GetFLLUseDependencyInjection(); + if (useDI) + { + // If we use dependency injection, we don't need to check for this context property + return; + } + + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + var pluginClassInfo = Helper.GetPluginClassInfo(classDeclaration, semanticModel, context.CancellationToken); + if (pluginClassInfo == null) + { + // Cannot find class that implements IPluginI18n + return; + } + + // Context property is found, check if it's a valid property + if (pluginClassInfo.PropertyName != null) + { + if (!pluginClassInfo.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + AnalyzerDiagnostics.ContextIsNotStatic, + pluginClassInfo.CodeFixLocation + )); + return; + } + + if (pluginClassInfo.IsPrivate || pluginClassInfo.IsProtected) + { + context.ReportDiagnostic(Diagnostic.Create( + AnalyzerDiagnostics.ContextAccessIsTooRestrictive, + pluginClassInfo.CodeFixLocation + )); + return; + } + + // If the context property is valid, we don't need to check for anything else + return; + } + + // Context property is not found, check if it's declared as a field + var fieldDeclaration = classDeclaration.Members + .OfType() + .SelectMany(f => f.Declaration.Variables) + .Select(f => semanticModel.GetDeclaredSymbol(f)) + .FirstOrDefault(f => f is IFieldSymbol fs && fs.Type.Name is Constants.PluginContextTypeName); + var parentSyntax = fieldDeclaration + ?.DeclaringSyntaxReferences[0] + .GetSyntax() + .FirstAncestorOrSelf(); + if (parentSyntax != null) + { + context.ReportDiagnostic(Diagnostic.Create( + AnalyzerDiagnostics.ContextIsAField, + parentSyntax.GetLocation() + )); + return; + } + + // Context property is not found, report an error + context.ReportDiagnostic(Diagnostic.Create( + AnalyzerDiagnostics.ContextIsNotDeclared, + classDeclaration.Identifier.GetLocation() + )); + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzerCodeFixProvider.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzerCodeFixProvider.cs new file mode 100644 index 00000000000..8ef920c37cc --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/ContextAvailabilityAnalyzerCodeFixProvider.cs @@ -0,0 +1,201 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.Analyzers.Localize +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ContextAvailabilityAnalyzerCodeFixProvider)), Shared] + public class ContextAvailabilityAnalyzerCodeFixProvider : CodeFixProvider + { + #region CodeFixProvider + + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + AnalyzerDiagnostics.ContextIsAField.Id, + AnalyzerDiagnostics.ContextIsNotStatic.Id, + AnalyzerDiagnostics.ContextAccessIsTooRestrictive.Id, + AnalyzerDiagnostics.ContextIsNotDeclared.Id + ); + + public sealed override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + if (diagnostic.Id == AnalyzerDiagnostics.ContextIsAField.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with static property", + createChangedDocument: _ => Task.FromResult(FixContextIsAFieldError(context, root, diagnosticSpan)), + equivalenceKey: AnalyzerDiagnostics.ContextIsAField.Id + ), + diagnostic + ); + } + else if (diagnostic.Id == AnalyzerDiagnostics.ContextIsNotStatic.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Make static", + createChangedDocument: _ => Task.FromResult(FixContextIsNotStaticError(context, root, diagnosticSpan)), + equivalenceKey: AnalyzerDiagnostics.ContextIsNotStatic.Id + ), + diagnostic + ); + } + else if (diagnostic.Id == AnalyzerDiagnostics.ContextAccessIsTooRestrictive.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Make internal", + createChangedDocument: _ => Task.FromResult(FixContextIsTooRestricted(context, root, diagnosticSpan)), + equivalenceKey: AnalyzerDiagnostics.ContextAccessIsTooRestrictive.Id + ), + diagnostic + ); + } + else if (diagnostic.Id == AnalyzerDiagnostics.ContextIsNotDeclared.Id) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Declare context property", + createChangedDocument: _ => Task.FromResult(FixContextNotDeclared(context, root, diagnosticSpan)), + equivalenceKey: AnalyzerDiagnostics.ContextIsNotDeclared.Id + ), + diagnostic + ); + } + } + + #endregion + + #region Fix Methods + + private static Document FixContextNotDeclared(CodeFixContext context, SyntaxNode root, TextSpan diagnosticSpan) + { + var classDeclaration = GetDeclarationSyntax(root, diagnosticSpan); + if (classDeclaration?.BaseList is null) return context.Document; + + var newPropertyDeclaration = GetStaticContextPropertyDeclaration(); + if (newPropertyDeclaration is null) return context.Document; + + var annotatedNewPropertyDeclaration = newPropertyDeclaration + .WithLeadingTrivia(SyntaxFactory.ElasticLineFeed) + .WithTrailingTrivia(SyntaxFactory.ElasticLineFeed) + .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation); + + var newMembers = classDeclaration.Members.Insert(0, annotatedNewPropertyDeclaration); + var newClassDeclaration = classDeclaration.WithMembers(newMembers); + + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + + return GetFormattedDocument(context, newRoot); + } + + private static Document FixContextIsNotStaticError(CodeFixContext context, SyntaxNode root, TextSpan diagnosticSpan) + { + var propertyDeclaration = GetDeclarationSyntax(root, diagnosticSpan); + if (propertyDeclaration is null) return context.Document; + + var newPropertyDeclaration = FixRestrictivePropertyModifiers(propertyDeclaration).AddModifiers(SyntaxFactory.Token(SyntaxKind.StaticKeyword)); + + var newRoot = root.ReplaceNode(propertyDeclaration, newPropertyDeclaration); + return context.Document.WithSyntaxRoot(newRoot); + } + + private static Document FixContextIsTooRestricted(CodeFixContext context, SyntaxNode root, TextSpan diagnosticSpan) + { + var propertyDeclaration = GetDeclarationSyntax(root, diagnosticSpan); + if (propertyDeclaration is null) return context.Document; + + var newPropertyDeclaration = FixRestrictivePropertyModifiers(propertyDeclaration); + + var newRoot = root.ReplaceNode(propertyDeclaration, newPropertyDeclaration); + return context.Document.WithSyntaxRoot(newRoot); + } + + private static Document FixContextIsAFieldError(CodeFixContext context, SyntaxNode root, TextSpan diagnosticSpan) { + var fieldDeclaration = GetDeclarationSyntax(root, diagnosticSpan); + if (fieldDeclaration is null) return context.Document; + + var field = fieldDeclaration.Declaration.Variables.First(); + var fieldIdentifier = field.Identifier.ToString(); + + var propertyDeclaration = GetStaticContextPropertyDeclaration(fieldIdentifier); + if (propertyDeclaration is null) return context.Document; + + var annotatedNewPropertyDeclaration = propertyDeclaration + .WithTriviaFrom(fieldDeclaration) + .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation); + + var newRoot = root.ReplaceNode(fieldDeclaration, annotatedNewPropertyDeclaration); + + return GetFormattedDocument(context, newRoot); + } + + #region Utils + + private static MemberDeclarationSyntax GetStaticContextPropertyDeclaration(string propertyName = "Context") => + SyntaxFactory.ParseMemberDeclaration( + $"internal static {Constants.PluginContextTypeName} {propertyName} {{ get; private set; }} = null!;" + ); + + private static Document GetFormattedDocument(CodeFixContext context, SyntaxNode root) + { + var formattedRoot = Formatter.Format( + root, + Formatter.Annotation, + context.Document.Project.Solution.Workspace + ); + + return context.Document.WithSyntaxRoot(formattedRoot); + } + + private static PropertyDeclarationSyntax FixRestrictivePropertyModifiers(PropertyDeclarationSyntax propertyDeclaration) + { + var newModifiers = SyntaxFactory.TokenList(); + foreach (var modifier in propertyDeclaration.Modifiers) + { + if (modifier.IsKind(SyntaxKind.PrivateKeyword) || modifier.IsKind(SyntaxKind.ProtectedKeyword)) + { + newModifiers = newModifiers.Add(SyntaxFactory.Token(SyntaxKind.InternalKeyword)); + } + else + { + newModifiers = newModifiers.Add(modifier); + } + } + + return propertyDeclaration.WithModifiers(newModifiers); + } + + private static T GetDeclarationSyntax(SyntaxNode root, TextSpan diagnosticSpan) where T : SyntaxNode => + root + .FindToken(diagnosticSpan.Start) + .Parent + ?.AncestorsAndSelf() + .OfType() + .First(); + + #endregion + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzer.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzer.cs new file mode 100644 index 00000000000..9577a8cc314 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzer.cs @@ -0,0 +1,130 @@ +using System.Collections.Immutable; +using System.Linq; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Flow.Launcher.Localization.Analyzers.Localize +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class OldGetTranslateAnalyzer : DiagnosticAnalyzer + { + #region DiagnosticAnalyzer + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + AnalyzerDiagnostics.OldLocalizationApiUsed + ); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.InvocationExpression); + } + + #endregion + + #region Analyze Methods + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + var semanticModel = context.SemanticModel; + var symbolInfo = semanticModel.GetSymbolInfo(invocationExpr); + + // Check if the method is a format string call + if (!(symbolInfo.Symbol is IMethodSymbol methodSymbol)) return; + + // First branch: detect a call to string.Format containing a translate call anywhere in its arguments. + if (IsFormatStringCall(methodSymbol)) + { + var arguments = invocationExpr.ArgumentList.Arguments; + // Check all arguments is an invocation (i.e. a candidate for Context.API.GetTranslation(…)) + for (int i = 0; i < arguments.Count; i++) + { + if (GetArgumentInvocationExpression(invocationExpr, i) is InvocationExpressionSyntax innerInvocationExpr && + IsTranslateCall(semanticModel.GetSymbolInfo(innerInvocationExpr)) && + GetFirstArgumentStringValue(innerInvocationExpr) is string translationKey) + { + var diagnostic = Diagnostic.Create( + AnalyzerDiagnostics.OldLocalizationApiUsed, + invocationExpr.GetLocation(), + translationKey, + GetInvocationArguments(invocationExpr, i) + ); + context.ReportDiagnostic(diagnostic); + return; + } + } + } + // Second branch: direct translate call (outside of a Format call) + else if (IsTranslateCall(methodSymbol) && GetFirstArgumentStringValue(invocationExpr) is string translationKey) + { + if (IsParentFormatStringCall(semanticModel, invocationExpr)) return; + + var diagnostic = Diagnostic.Create( + AnalyzerDiagnostics.OldLocalizationApiUsed, + invocationExpr.GetLocation(), + translationKey, + string.Empty + ); + context.ReportDiagnostic(diagnostic); + } + } + + #region Utils + + private static string GetInvocationArguments(InvocationExpressionSyntax invocationExpr, int translateArgIndex) => + string.Join(", ", invocationExpr.ArgumentList.Arguments.Skip(translateArgIndex + 1)); + + /// + /// Walk up the tree to see if we're already inside a Format call + /// + private static bool IsParentFormatStringCall(SemanticModel semanticModel, SyntaxNode syntaxNode) + { + var parent = syntaxNode.Parent; + while (parent != null) + { + if (parent is InvocationExpressionSyntax parentInvocation) + { + var symbol = semanticModel.GetSymbolInfo(parentInvocation).Symbol as IMethodSymbol; + if (IsFormatStringCall(symbol)) + { + return true; + } + } + parent = parent.Parent; + } + return false; + } + + private static bool IsFormatStringCall(IMethodSymbol methodSymbol) => + methodSymbol?.Name == Constants.StringFormatMethodName && + methodSymbol.ContainingType.ToDisplayString() == Constants.StringFormatTypeName; + + private static InvocationExpressionSyntax GetArgumentInvocationExpression(InvocationExpressionSyntax invocationExpr, int index) => + invocationExpr.ArgumentList.Arguments[index].Expression as InvocationExpressionSyntax; + + private static bool IsTranslateCall(SymbolInfo symbolInfo) => + symbolInfo.Symbol is IMethodSymbol innerMethodSymbol && + innerMethodSymbol.Name == Constants.OldLocalizationMethodName && + Constants.OldLocalizationClasses.Contains(innerMethodSymbol.ContainingType.Name); + + private static bool IsTranslateCall(IMethodSymbol methodSymbol) => + methodSymbol?.Name is Constants.OldLocalizationMethodName && + Constants.OldLocalizationClasses.Contains(methodSymbol.ContainingType.Name); + + private static string GetFirstArgumentStringValue(InvocationExpressionSyntax invocationExpr) + { + if (invocationExpr.ArgumentList.Arguments.FirstOrDefault()?.Expression is LiteralExpressionSyntax syntax) + return syntax.Token.ValueText; + return null; + } + + #endregion + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzerCodeFixProvider.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzerCodeFixProvider.cs new file mode 100644 index 00000000000..7e73b87962b --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Analyzers/Localize/OldGetTranslateAnalyzerCodeFixProvider.cs @@ -0,0 +1,141 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Flow.Launcher.Localization.Analyzers.Localize +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OldGetTranslateAnalyzerCodeFixProvider)), Shared] + public class OldGetTranslateAnalyzerCodeFixProvider : CodeFixProvider + { + #region CodeFixProvider + + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + AnalyzerDiagnostics.OldLocalizationApiUsed.Id + ); + + public sealed override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + var diagnostic = context.Diagnostics.First(); + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Replace with '{Constants.ClassName}.localization_key(...args)'", + createChangedDocument: _ => Task.FromResult(FixOldTranslation(context, root, diagnostic)), + equivalenceKey: AnalyzerDiagnostics.OldLocalizationApiUsed.Id + ), + diagnostic + ); + } + + #endregion + + #region Fix Methods + + private static Document FixOldTranslation(CodeFixContext context, SyntaxNode root, Diagnostic diagnostic) + { + var diagnosticSpan = diagnostic.Location.SourceSpan; + + if (root is null) return context.Document; + + var invocationExpr = root + .FindToken(diagnosticSpan.Start).Parent + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (invocationExpr is null) return context.Document; + + var argumentList = invocationExpr.ArgumentList.Arguments; + + // Loop through the arguments to find the translation key. + for (int i = 0; i < argumentList.Count; i++) + { + var argument = argumentList[i].Expression; + + // Case 1: The argument is a literal (direct GetTranslation("key")) + if (GetTranslationKey(argument) is string translationKey) + return FixOldTranslationWithoutStringFormat(context, translationKey, root, invocationExpr); + + // Case 2: The argument is itself an invocation (nested GetTranslation) + if (GetTranslationKeyFromInnerInvocation(argument) is string translationKeyInside) + { + // If there are arguments following this translation call, treat as a Format call. + if (i < argumentList.Count - 1) + return FixOldTranslationWithStringFormat(context, argumentList, translationKeyInside, root, invocationExpr, i); + + // Otherwise, treat it as a direct translation call. + return FixOldTranslationWithoutStringFormat(context, translationKeyInside, root, invocationExpr); + } + } + + return context.Document; + } + + #region Utils + + private static string GetTranslationKey(ExpressionSyntax syntax) + { + if (syntax is LiteralExpressionSyntax literalExpressionSyntax && + literalExpressionSyntax.Token.Value is string translationKey) + return translationKey; + return null; + } + + private static Document FixOldTranslationWithoutStringFormat( + CodeFixContext context, string translationKey, SyntaxNode root, InvocationExpressionSyntax invocationExpr) + { + var newInvocationExpr = SyntaxFactory.ParseExpression( + $"{Constants.ClassName}.{translationKey}()" + ); + + var newRoot = root.ReplaceNode(invocationExpr, newInvocationExpr); + var newDocument = context.Document.WithSyntaxRoot(newRoot); + return newDocument; + } + + private static string GetTranslationKeyFromInnerInvocation(ExpressionSyntax syntax) + { + if (syntax is InvocationExpressionSyntax invocationExpressionSyntax && + invocationExpressionSyntax.ArgumentList.Arguments.Count == 1) + { + var firstArgument = invocationExpressionSyntax.ArgumentList.Arguments.First().Expression; + return GetTranslationKey(firstArgument); + } + return null; + } + + private static Document FixOldTranslationWithStringFormat( + CodeFixContext context, + SeparatedSyntaxList argumentList, + string translationKey2, + SyntaxNode root, + InvocationExpressionSyntax invocationExpr, + int translationArgIndex) + { + // Skip all arguments before and including the translation call + var newArguments = string.Join(", ", argumentList.Skip(translationArgIndex + 1).Select(a => a.Expression)); + var newInnerInvocationExpr = SyntaxFactory.ParseExpression($"{Constants.ClassName}.{translationKey2}({newArguments})"); + + var newRoot = root.ReplaceNode(invocationExpr, newInnerInvocationExpr); + return context.Document.WithSyntaxRoot(newRoot); + } + + #endregion + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs new file mode 100644 index 00000000000..21f2a06f0ef --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark an enum for localization. + /// + [AttributeUsage(AttributeTargets.Enum)] + public class EnumLocalizeAttribute : Attribute + { + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs new file mode 100644 index 00000000000..39d75eb14ab --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs @@ -0,0 +1,33 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark a localization key for an enum field. + /// + [AttributeUsage(AttributeTargets.Field)] + public class EnumLocalizeKeyAttribute : Attribute + { + public static readonly EnumLocalizeKeyAttribute Default = new EnumLocalizeKeyAttribute(); + + public EnumLocalizeKeyAttribute() : this(string.Empty) + { + } + + public EnumLocalizeKeyAttribute(string enumLocalizeKey) + { + EnumLocalizeKey = enumLocalizeKey; + } + + public virtual string LocalizeKey => EnumLocalizeKey; + + protected string EnumLocalizeKey { get; set; } + + public override bool Equals(object obj) => + obj is EnumLocalizeKeyAttribute other && other.LocalizeKey == LocalizeKey; + + public override int GetHashCode() => LocalizeKey?.GetHashCode() ?? 0; + + public override bool IsDefaultAttribute() => Equals(Default); + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs new file mode 100644 index 00000000000..75c3859a761 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs @@ -0,0 +1,33 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark a localization value for an enum field. + /// + [AttributeUsage(AttributeTargets.Field)] + public class EnumLocalizeValueAttribute : Attribute + { + public static readonly EnumLocalizeValueAttribute Default = new EnumLocalizeValueAttribute(); + + public EnumLocalizeValueAttribute() : this(string.Empty) + { + } + + public EnumLocalizeValueAttribute(string enumLocalizeValue) + { + EnumLocalizeValue = enumLocalizeValue; + } + + public virtual string LocalizeValue => EnumLocalizeValue; + + protected string EnumLocalizeValue { get; set; } + + public override bool Equals(object obj) => + obj is EnumLocalizeValueAttribute other && other.LocalizeValue == LocalizeValue; + + public override int GetHashCode() => LocalizeValue?.GetHashCode() ?? 0; + + public override bool IsDefaultAttribute() => Equals(Default); + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj new file mode 100644 index 00000000000..dd973d9abc9 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + Flow.Launcher.Localization.Attributes + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Classes.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Classes.cs new file mode 100644 index 00000000000..9c78d98a330 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Classes.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; + +namespace Flow.Launcher.Localization.Shared +{ + public class PluginClassInfo + { + public Location Location { get; } + public string ClassName { get; } + public string PropertyName { get; } + public bool IsStatic { get; } + public bool IsPrivate { get; } + public bool IsProtected { get; } + public Location CodeFixLocation { get; } + + public string ContextAccessor => $"{ClassName}.{PropertyName}"; + public bool IsValid => PropertyName != null && IsStatic && (!IsPrivate) && (!IsProtected); + + public PluginClassInfo(Location location, string className, string propertyName, bool isStatic, bool isPrivate, bool isProtected, Location codeFixLocation) + { + Location = location; + ClassName = className; + PropertyName = propertyName; + IsStatic = isStatic; + IsPrivate = isPrivate; + IsProtected = isProtected; + CodeFixLocation = codeFixLocation; + } + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Constants.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Constants.cs new file mode 100644 index 00000000000..3b3fe069609 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Constants.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace Flow.Launcher.Localization.Shared +{ + public static class Constants + { + public const string DefaultNamespace = "Flow.Launcher"; + public const string ClassName = "Localize"; + public const string PluginInterfaceName = "IPluginI18n"; + public const string PluginContextTypeName = "PluginInitContext"; + public const string SystemPrefixUri = "clr-namespace:System;assembly=mscorlib"; + public const string XamlPrefixUri = "http://schemas.microsoft.com/winfx/2006/xaml"; + public const string XamlTag = "String"; + public const string KeyAttribute = "Key"; + public const string SummaryElementName = "summary"; + public const string ParamElementName = "param"; + public const string IndexAttribute = "index"; + public const string NameAttribute = "name"; + public const string TypeAttribute = "type"; + public const string OldLocalizationMethodName = "GetTranslation"; + public const string StringFormatMethodName = "Format"; + public const string StringFormatTypeName = "string"; + public const string EnumLocalizeClassSuffix = "Localized"; + public const string EnumLocalizeAttributeName = "EnumLocalizeAttribute"; + public const string EnumLocalizeKeyAttributeName = "EnumLocalizeKeyAttribute"; + public const string EnumLocalizeValueAttributeName = "EnumLocalizeValueAttribute"; + // Use PublicApi instead of PublicAPI for possible ambiguity with Flow.Launcher.Plugin.IPublicAPI + public const string PublicApiClassName = "PublicApi"; + public const string PublicApiPrivatePropertyName = "instance"; + public const string PublicApiInternalPropertyName = "Instance"; + + public static readonly Regex LanguagesXamlRegex = new Regex(@"\\Languages\\[^\\]+\.xaml$", RegexOptions.IgnoreCase); + public static readonly string[] OldLocalizationClasses = { "IPublicAPI", "Internationalization" }; + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Flow.Launcher.Localization.Shared.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Flow.Launcher.Localization.Shared.csproj new file mode 100644 index 00000000000..b8c2598eb6e --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Flow.Launcher.Localization.Shared.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + true + Flow.Launcher.Localization.Shared + + + + + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Helper.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Helper.cs new file mode 100644 index 00000000000..0807ceff88a --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.Shared/Helper.cs @@ -0,0 +1,94 @@ +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Flow.Launcher.Localization.Shared +{ + public static class Helper + { + #region Build Properties + + public static bool GetFLLUseDependencyInjection(this AnalyzerConfigOptionsProvider configOptions) + { + if (!configOptions.GlobalOptions.TryGetValue("build_property.FLLUseDependencyInjection", out var result) || + !bool.TryParse(result, out var useDI)) + { + return false; // Default to false + } + return useDI; + } + + #endregion + + #region Plugin Class Info + + /// + /// If cannot find the class that implements IPluginI18n, return null. + /// If cannot find the context property, return PluginClassInfo with null context property name. + /// + public static PluginClassInfo GetPluginClassInfo(ClassDeclarationSyntax classDecl, SemanticModel semanticModel, CancellationToken ct) + { + var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, ct); + if (!IsPluginEntryClass(classSymbol)) + { + // Cannot find class that implements IPluginI18n + return null; + } + + var property = GetContextProperty(classDecl); + var location = GetLocation(semanticModel.SyntaxTree, classDecl); + if (property is null) + { + // Cannot find context + return new PluginClassInfo(location, classDecl.Identifier.Text, null, false, false, false, null); + } + + var modifiers = property.Modifiers; + var codeFixLocation = GetCodeFixLocation(property, semanticModel); + return new PluginClassInfo( + location, + classDecl.Identifier.Text, + property.Identifier.Text, + modifiers.Any(SyntaxKind.StaticKeyword), + modifiers.Any(SyntaxKind.PrivateKeyword), + modifiers.Any(SyntaxKind.ProtectedKeyword), + codeFixLocation); + } + + private static bool IsPluginEntryClass(INamedTypeSymbol namedTypeSymbol) + { + return namedTypeSymbol?.Interfaces.Any(i => i.Name == Constants.PluginInterfaceName) ?? false; + } + + private static PropertyDeclarationSyntax GetContextProperty(ClassDeclarationSyntax classDecl) + { + return classDecl.Members + .OfType() + .FirstOrDefault(p => p.Type.ToString() == Constants.PluginContextTypeName); + } + + private static Location GetLocation(SyntaxTree syntaxTree, CSharpSyntaxNode classDeclaration) + { + return Location.Create(syntaxTree, classDeclaration.GetLocation().SourceSpan); + } + + private static Location GetCodeFixLocation(PropertyDeclarationSyntax property, SemanticModel semanticModel) + { + return semanticModel.GetDeclaredSymbol(property).DeclaringSyntaxReferences[0].GetSyntax().GetLocation(); + } + + #endregion + + #region Tab String + + public static string Spacing(int n) + { + return new string(' ', n * 4); + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Shipped.md b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000000..5ccc9f037f6 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000000..c1ad7a3758c --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,14 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +FLSG0001 | Localization | Warning | FLSG0001_CouldNotFindResourceDictionaries +FLSG0002 | Localization | Warning | FLSG0002_CouldNotFindPluginEntryClass +FLSG0003 | Localization | Warning | FLSG0003_CouldNotFindContextProperty +FLSG0004 | Localization | Warning | FLSG0004_ContextPropertyNotStatic +FLSG0005 | Localization | Warning | FLSG0005_ContextPropertyIsPrivate +FLSG0006 | Localization | Warning | FLSG0006_ContextPropertyIsProtected +FLSG0007 | Localization | Warning | FLSG0007_LocalizationKeyUnused +FLSG0008 | Localization | Warning | FLSG0008_EnumFieldLocalizationKeyValueInvalid diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj new file mode 100644 index 00000000000..6835526926a --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + true + Flow.Launcher.Localization.SourceGenerators + + 0.0.3 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs new file mode 100644 index 00000000000..76a218f2128 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.SourceGenerators.Localize +{ + [Generator] + public partial class EnumSourceGenerator : IIncrementalGenerator + { + #region Fields + + private static readonly Version PackageVersion = typeof(EnumSourceGenerator).Assembly.GetName().Version; + + private static readonly ImmutableArray _emptyEnumFields = ImmutableArray.Empty; + + #endregion + + #region Incremental Generator + + /// + /// Initializes the generator and registers source output based on enum declarations. + /// + /// The initialization context. + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var enumDeclarations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (s, _) => s is EnumDeclarationSyntax, + transform: (ctx, _) => (EnumDeclarationSyntax)ctx.Node) + .Where(ed => ed.AttributeLists.Count > 0) + .Collect(); + + var pluginClasses = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (n, _) => n is ClassDeclarationSyntax, + transform: (c, t) => Helper.GetPluginClassInfo((ClassDeclarationSyntax)c.Node, c.SemanticModel, t)) + .Where(info => info != null) + .Collect(); + + var compilation = context.CompilationProvider; + + var configOptions = context.AnalyzerConfigOptionsProvider; + + var compilationEnums = enumDeclarations.Combine(pluginClasses).Combine(configOptions).Combine(compilation); + + context.RegisterSourceOutput(compilationEnums, Execute); + } + + /// + /// Executes the generation of enum data classes based on the provided data. + /// + /// The source production context. + /// The provided data. + private void Execute(SourceProductionContext spc, + (((ImmutableArray EnumsDeclarations, + ImmutableArray PluginClassInfos), + AnalyzerConfigOptionsProvider ConfigOptionsProvider), + Compilation Compilation) data) + { + var compilation = data.Compilation; + var configOptions = data.Item1.ConfigOptionsProvider; + var pluginClasses = data.Item1.Item1.PluginClassInfos; + var enumsDeclarations = data.Item1.Item1.EnumsDeclarations; + + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; + var useDI = configOptions.GetFLLUseDependencyInjection(); + + PluginClassInfo pluginInfo; + if (useDI) + { + // If we use dependency injection, we do not need to check if there is a valid plugin context + pluginInfo = null; + } + else + { + pluginInfo = PluginInfoHelper.GetValidPluginInfoAndReportDiagnostic(pluginClasses, spc); + if (pluginInfo == null) + { + // If we cannot find a valid plugin info, we do not need to generate the source + return; + } + } + + foreach (var enumDeclaration in enumsDeclarations.Distinct()) + { + var semanticModel = compilation.GetSemanticModel(enumDeclaration.SyntaxTree); + var enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclaration) as INamedTypeSymbol; + + // Check if the enum has the EnumLocalize attribute + if (enumSymbol?.GetAttributes().Any(ad => + ad.AttributeClass?.Name == Constants.EnumLocalizeAttributeName) ?? false) + { + GenerateSource(spc, enumSymbol, useDI, pluginInfo, assemblyNamespace); + } + } + } + + #endregion + + #region Get Enum Fields + + private static ImmutableArray GetEnumFields(SourceProductionContext spc, INamedTypeSymbol enumSymbol, string enumFullName) + { + // Iterate through enum members and get enum fields + var enumFields = new List(); + var enumError = false; + foreach (var member in enumSymbol.GetMembers().Where(m => m.Kind == SymbolKind.Field)) + { + if (member is IFieldSymbol fieldSymbol) + { + var enumFieldName = fieldSymbol.Name; + + // Check if the field has the EnumLocalizeKey attribute + var keyAttr = fieldSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == Constants.EnumLocalizeKeyAttributeName); + var keyAttrExist = keyAttr != null; + + // Check if the field has the EnumLocalizeValue attribute + var valueAttr = fieldSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == Constants.EnumLocalizeValueAttributeName); + var valueAttrExist = valueAttr != null; + + // Get the key and value from the attributes + var key = keyAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString() ?? string.Empty; + var value = valueAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString() ?? string.Empty; + + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + if (keyAttrExist && !string.IsNullOrWhiteSpace(key)) + { + // If localization key exists and is valid, use it + enumFields.Add(new EnumField(enumFieldName, key, valueAttrExist ? value : null)); + } + else if (valueAttrExist) + { + // If localization value exists, use it + enumFields.Add(new EnumField(enumFieldName, value)); + } + else + { + // If localization key and value are not provided, do not generate the field and report a diagnostic + spc.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.EnumFieldLocalizationKeyValueInvalid, + Location.None, + $"{enumFullName}.{enumFieldName}")); + enumError = true; + } + } + } + + // If there was an error, do not generate the class + if (enumError) return _emptyEnumFields; + + return enumFields.ToImmutableArray(); + } + + #endregion + + #region Generate Source + + private void GenerateSource( + SourceProductionContext spc, + INamedTypeSymbol enumSymbol, + bool useDI, + PluginClassInfo pluginInfo, + string assemblyNamespace) + { + var enumFullName = enumSymbol.ToDisplayString(new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, // Remove global:: symbol + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces)); + var enumDataClassName = $"{enumSymbol.Name}{Constants.EnumLocalizeClassSuffix}"; + var enumName = enumSymbol.Name; + var enumNamespace = enumSymbol.ContainingNamespace.ToDisplayString(); + var tabString = Helper.Spacing(1); + + var sourceBuilder = new StringBuilder(); + + // Generate header + GeneratedHeaderFromPath(sourceBuilder, enumFullName); + sourceBuilder.AppendLine(); + + // Generate using directives + sourceBuilder.AppendLine("using System.Collections.Generic;"); + sourceBuilder.AppendLine(); + + // Generate namespace + sourceBuilder.AppendLine($"namespace {enumNamespace};"); + sourceBuilder.AppendLine(); + + // Generate class + sourceBuilder.AppendLine($"/// "); + sourceBuilder.AppendLine($"/// Data class for "); + sourceBuilder.AppendLine($"/// "); + sourceBuilder.AppendLine($"[System.CodeDom.Compiler.GeneratedCode(\"{nameof(EnumSourceGenerator)}\", \"{PackageVersion}\")]"); + sourceBuilder.AppendLine($"public class {enumDataClassName}"); + sourceBuilder.AppendLine("{"); + + // Generate properties + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The value of the enum"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public {enumName} Value {{ get; private init; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The display text of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string Display {{ get; set; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The localization key of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string LocalizationKey {{ get; set; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The localization value of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string LocalizationValue {{ get; set; }}"); + sourceBuilder.AppendLine(); + + // Generate API instance + string getTranslation = null; + if (useDI) + { + // Use instance from PublicApiSourceGenerator + getTranslation = $"{assemblyNamespace}.{Constants.PublicApiClassName}.{Constants.PublicApiInternalPropertyName}.GetTranslation"; + } + else if (pluginInfo?.IsValid == true) + { + getTranslation = $"{assemblyNamespace}.{pluginInfo.ContextAccessor}.API.GetTranslation"; + } + + // Generate GetValues method + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// Get all values of "); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public static List<{enumDataClassName}> GetValues()"); + sourceBuilder.AppendLine($"{tabString}{{"); + sourceBuilder.AppendLine($"{tabString}{tabString}return new List<{enumDataClassName}>"); + sourceBuilder.AppendLine($"{tabString}{tabString}{{"); + var enumFields = GetEnumFields(spc, enumSymbol, enumFullName); + if (enumFields.Length == 0) return; + foreach (var enumField in enumFields) + { + GenerateEnumField(sourceBuilder, getTranslation, enumField, enumName, tabString); + } + sourceBuilder.AppendLine($"{tabString}{tabString}}};"); + sourceBuilder.AppendLine($"{tabString}}}"); + sourceBuilder.AppendLine(); + + // Generate UpdateLabels method + GenerateUpdateLabelsMethod(sourceBuilder, getTranslation, enumDataClassName, tabString); + + sourceBuilder.AppendLine($"}}"); + + // Add source to context + spc.AddSource($"{Constants.ClassName}.{assemblyNamespace}.{enumNamespace}.{enumDataClassName}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + } + + private static void GeneratedHeaderFromPath(StringBuilder sb, string enumFullName) + { + if (string.IsNullOrEmpty(enumFullName)) + { + sb.AppendLine("/// "); + } + else + { + sb.AppendLine("/// ") + .AppendLine($"/// From: {enumFullName}") + .AppendLine("/// "); + } + } + + private static void GenerateEnumField( + StringBuilder sb, + string getTranslation, + EnumField enumField, + string enumName, + string tabString) + { + sb.AppendLine($"{tabString}{tabString}{tabString}new()"); + sb.AppendLine($"{tabString}{tabString}{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Value = {enumName}.{enumField.EnumFieldName},"); + if (enumField.UseLocalizationKey) + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Display = {getTranslation}(\"{enumField.LocalizationKey}\"),"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}LocalizationKey = \"{enumField.LocalizationKey}\","); + } + else + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Display = \"{enumField.LocalizationValue}\","); + } + if (enumField.LocalizationValue != null) + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}LocalizationValue = \"{enumField.LocalizationValue}\","); + } + sb.AppendLine($"{tabString}{tabString}{tabString}}},"); + } + + private static void GenerateUpdateLabelsMethod( + StringBuilder sb, + string getTranslation, + string enumDataClassName, + string tabString) + { + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}/// Update the labels of the enum values when culture info changes."); + sb.AppendLine($"{tabString}/// See for more details"); + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}public static void UpdateLabels(List<{enumDataClassName}> options)"); + sb.AppendLine($"{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}foreach (var item in options)"); + sb.AppendLine($"{tabString}{tabString}{{"); + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + sb.AppendLine($"{tabString}{tabString}{tabString}if (!string.IsNullOrWhiteSpace(item.LocalizationKey))"); + sb.AppendLine($"{tabString}{tabString}{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}item.Display = {getTranslation}(item.LocalizationKey);"); + sb.AppendLine($"{tabString}{tabString}{tabString}}}"); + sb.AppendLine($"{tabString}{tabString}}}"); + sb.AppendLine($"{tabString}}}"); + } + + #endregion + + #region Classes + + public class EnumField + { + public string EnumFieldName { get; set; } + public string LocalizationKey { get; set; } + public string LocalizationValue { get; set; } + + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + public bool UseLocalizationKey => !string.IsNullOrWhiteSpace(LocalizationKey); + + public EnumField(string enumFieldName, string localizationValue) : this(enumFieldName, null, localizationValue) + { + } + + public EnumField(string enumFieldName, string localizationKey, string localizationValue) + { + EnumFieldName = enumFieldName; + LocalizationKey = localizationKey; + LocalizationValue = localizationValue; + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs new file mode 100644 index 00000000000..43589cea597 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Xml.Linq; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.SourceGenerators.Localize +{ + /// + /// Generates properties for strings based on resource files. + /// + [Generator] + public partial class LocalizeSourceGenerator : IIncrementalGenerator + { + #region Fields + + private static readonly Version PackageVersion = typeof(LocalizeSourceGenerator).Assembly.GetName().Version; + + private static readonly ImmutableArray _emptyLocalizableStrings = ImmutableArray.Empty; + private static readonly ImmutableArray _emptyLocalizableStringParams = ImmutableArray.Empty; + + #endregion + + #region Incremental Generator + + /// + /// Initializes the generator and registers source output based on resource files. + /// + /// The initialization context. + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var xamlFiles = context.AdditionalTextsProvider + .Where(file => Constants.LanguagesXamlRegex.IsMatch(file.Path)); + + var localizedStrings = xamlFiles + .Select((file, ct) => ParseXamlFile(file, ct)) + .Collect() + .SelectMany((files, _) => files); + + var invocationKeys = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (n, _) => n is InvocationExpressionSyntax, + transform: GetLocalizationKeyFromInvocation) + .Where(key => !string.IsNullOrEmpty(key)) + .Collect() + .Select((keys, _) => keys.Distinct().ToImmutableHashSet()); + + var pluginClasses = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (n, _) => n is ClassDeclarationSyntax, + transform: (c, t) => Helper.GetPluginClassInfo((ClassDeclarationSyntax)c.Node, c.SemanticModel, t)) + .Where(info => info != null) + .Collect(); + + var compilation = context.CompilationProvider; + + var configOptions = context.AnalyzerConfigOptionsProvider; + + var combined = localizedStrings.Combine(invocationKeys).Combine(pluginClasses).Combine(configOptions).Combine(compilation).Combine(xamlFiles.Collect()); + + context.RegisterSourceOutput(combined, Execute); + } + + /// + /// Executes the generation of string properties based on the provided data. + /// + /// The source production context. + /// The provided data. + private void Execute(SourceProductionContext spc, + (((((ImmutableArray LocalizableStrings, + ImmutableHashSet InvocationKeys), + ImmutableArray PluginClassInfos), + AnalyzerConfigOptionsProvider ConfigOptionsProvider), + Compilation Compilation), + ImmutableArray AdditionalTexts) data) + { + var xamlFiles = data.AdditionalTexts; + if (xamlFiles.Length == 0) + { + spc.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.CouldNotFindResourceDictionaries, + Location.None + )); + return; + } + + var compilation = data.Item1.Compilation; + var configOptions = data.Item1.Item1.ConfigOptionsProvider; + var pluginClasses = data.Item1.Item1.Item1.PluginClassInfos; + var usedKeys = data.Item1.Item1.Item1.Item1.InvocationKeys; + var localizedStrings = data.Item1.Item1.Item1.Item1.LocalizableStrings; + + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; + var useDI = configOptions.GetFLLUseDependencyInjection(); + + PluginClassInfo pluginInfo; + if (useDI) + { + // If we use dependency injection, we do not need to check if there is a valid plugin context + pluginInfo = null; + } + else + { + pluginInfo = PluginInfoHelper.GetValidPluginInfoAndReportDiagnostic(pluginClasses, spc); + if (pluginInfo == null) + { + // If we cannot find a valid plugin info, we do not need to generate the source + return; + } + } + + GenerateSource( + spc, + xamlFiles[0], + localizedStrings, + assemblyNamespace, + useDI, + pluginInfo, + usedKeys); + } + + #endregion + + #region Parse Xaml File + + private static ImmutableArray ParseXamlFile(AdditionalText file, CancellationToken ct) + { + var content = file.GetText(ct)?.ToString(); + if (content is null) + { + return _emptyLocalizableStrings; + } + + var doc = XDocument.Parse(content); + var root = doc.Root; + if (root is null) + { + return _emptyLocalizableStrings; + } + + // Find prefixes for the target URIs + string systemPrefix = null; + string xamlPrefix = null; + + foreach (var attr in root.Attributes()) + { + // Check if the attribute is a namespace declaration (xmlns:...) + if (attr.Name.NamespaceName == XNamespace.Xmlns.NamespaceName) + { + string uri = attr.Value; + string prefix = attr.Name.LocalName; + + if (uri == Constants.SystemPrefixUri) + { + systemPrefix = prefix; + } + else if (uri == Constants.XamlPrefixUri) + { + xamlPrefix = prefix; + } + } + } + + if (systemPrefix is null || xamlPrefix is null) + { + return _emptyLocalizableStrings; + } + + var systemNs = doc.Root?.GetNamespaceOfPrefix(systemPrefix); + var xNs = doc.Root?.GetNamespaceOfPrefix(xamlPrefix); + if (systemNs is null || xNs is null) + { + return _emptyLocalizableStrings; + } + + var localizableStrings = new List(); + foreach (var element in doc.Descendants(systemNs + Constants.XamlTag)) // "String" elements in system namespace + { + if (ct.IsCancellationRequested) + { + return _emptyLocalizableStrings; + } + + var key = element.Attribute(xNs + Constants.KeyAttribute)?.Value; // "Key" attribute in xaml namespace + var value = element.Value; + var comment = element.PreviousNode as XComment; + + if (key != null) + { + var formatParams = GetParameters(value); + var (summary, updatedFormatParams) = ParseCommentAndUpdateParameters(comment, formatParams); + localizableStrings.Add(new LocalizableString(key, value, summary, updatedFormatParams)); + } + } + + return localizableStrings.ToImmutableArray(); + } + + /// + /// Analyzes the format string and returns a list of its parameters. + /// + /// + /// + private static List GetParameters(string format) + { + var parameters = new Dictionary(); + int maxIndex = -1; + int i = 0; + int len = format.Length; + + while (i < len) + { + if (format[i] == '{') + { + if (i + 1 < len && format[i + 1] == '{') + { + // Escaped '{', skip both + i += 2; + continue; + } + else + { + // Start of a format item, parse index and format + i++; // Move past '{' + int index = 0; + bool hasIndex = false; + + // Parse index + while (i < len && char.IsDigit(format[i])) + { + hasIndex = true; + index = index * 10 + (format[i] - '0'); + i++; + } + + if (!hasIndex) + { + // Skip invalid format item + while (i < len && format[i] != '}') + { + i++; + } + if (i < len) + { + i++; // Move past '}' + } + continue; + } + + // Check for alignment (comma followed by optional sign and digits) + if (i < len && format[i] == ',') + { + i++; // Skip comma and optional sign + if (i < len && (format[i] == '+' || format[i] == '-')) + { + i++; + } + // Skip digits + while (i < len && char.IsDigit(format[i])) + { + i++; + } + } + + string formatPart = null; + + // Check for format (after colon) + if (i < len && format[i] == ':') + { + i++; // Move past ':' + int start = i; + while (i < len && format[i] != '}') + { + i++; + } + formatPart = i < len ? format.Substring(start, i - start) : format.Substring(start); + if (i < len) + { + i++; // Move past '}' + } + } + else if (i < len && format[i] == '}') + { + // No format part + i++; // Move past '}' + } + else + { + // Invalid characters after index, skip to '}' + while (i < len && format[i] != '}') + { + i++; + } + if (i < len) + { + i++; // Move past '}' + } + } + + parameters[index] = formatPart; + if (index > maxIndex) + { + maxIndex = index; + } + } + } + else if (format[i] == '}') + { + // Handle possible escaped '}}' + if (i + 1 < len && format[i + 1] == '}') + { + i += 2; // Skip escaped '}}' + } + else + { + i++; // Move past '}' + } + } + else + { + i++; + } + } + + // Generate the result list from 0 to maxIndex + var result = new List(); + if (maxIndex == -1) + { + return result; + } + + for (int idx = 0; idx <= maxIndex; idx++) + { + var formatValue = parameters.TryGetValue(idx, out var value) ? value : null; + result.Add(new LocalizableStringParam { Index = idx, Format = formatValue, Name = $"arg{idx}", Type = "object?" }); + } + + return result; + } + + /// + /// Parses the comment and updates the format parameter names and types. + /// + /// + /// + /// + private static (string Summary, ImmutableArray Parameters) ParseCommentAndUpdateParameters(XComment comment, List parameters) + { + if (comment == null || comment.Value == null || parameters.Count == 0) + { + return (null, _emptyLocalizableStringParams); + } + + try + { + var doc = XDocument.Parse($"{comment.Value}"); + var summary = doc.Descendants(Constants.SummaryElementName).FirstOrDefault()?.Value.Trim(); + + // Update parameter names and types of the format string + foreach (var p in doc.Descendants(Constants.ParamElementName)) + { + var index = int.TryParse(p.Attribute(Constants.IndexAttribute).Value, out var intValue) ? intValue : -1; + var name = p.Attribute(Constants.NameAttribute).Value; + var type = p.Attribute(Constants.TypeAttribute).Value; + if (index >= 0 && index < parameters.Count) + { + if (!string.IsNullOrEmpty(name)) + { + parameters[index].Name = name; + } + if (!string.IsNullOrEmpty(type)) + { + parameters[index].Type = type; + } + } + } + return (summary, parameters.ToImmutableArray()); + } + catch + { + return (null, _emptyLocalizableStringParams); + } + } + + #endregion + + #region Get Used Localization Keys + + private static string GetLocalizationKeyFromInvocation(GeneratorSyntaxContext context, CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return null; + } + + var invocation = (InvocationExpressionSyntax)context.Node; + var expression = invocation.Expression; + var parts = new List(); + + // Traverse the member access hierarchy + while (expression is MemberAccessExpressionSyntax memberAccess) + { + parts.Add(memberAccess.Name.Identifier.Text); + expression = memberAccess.Expression; + } + + // Add the leftmost identifier + if (expression is IdentifierNameSyntax identifier) + { + parts.Add(identifier.Identifier.Text); + } + else + { + return null; + } + + // Reverse to get [ClassName, SubClass, Method] from [Method, SubClass, ClassName] + parts.Reverse(); + + // Check if the first part is ClassName and there's at least one more part + if (parts.Count < 2 || parts[0] != Constants.ClassName) + { + return null; + } + + return parts[1]; + } + + #endregion + + #region Generate Source + + private static void GenerateSource( + SourceProductionContext spc, + AdditionalText xamlFile, + ImmutableArray localizedStrings, + string assemblyNamespace, + bool useDI, + PluginClassInfo pluginInfo, + IEnumerable usedKeys) + { + // Report unusedKeys + var unusedKeys = localizedStrings + .Select(ls => ls.Key) + .ToImmutableHashSet() + .Except(usedKeys); + foreach (var key in unusedKeys) + { + spc.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.LocalizationKeyUnused, + Location.None, + key)); + } + + var sourceBuilder = new StringBuilder(); + + // Generate header + GeneratedHeaderFromPath(sourceBuilder, xamlFile.Path); + sourceBuilder.AppendLine(); + + // Generate nullable enable + sourceBuilder.AppendLine("#nullable enable"); + sourceBuilder.AppendLine(); + + // Generate namespace + sourceBuilder.AppendLine($"namespace {assemblyNamespace};"); + sourceBuilder.AppendLine(); + + // Uncomment them for debugging + //sourceBuilder.AppendLine("/*"); + /*// Generate all localization strings + sourceBuilder.AppendLine("localizedStrings"); + foreach (var ls in localizedStrings) + { + sourceBuilder.AppendLine($"{ls.Key} - {ls.Value}"); + } + sourceBuilder.AppendLine(); + + // Generate all unused keys + sourceBuilder.AppendLine("unusedKeys"); + foreach (var key in unusedKeys) + { + sourceBuilder.AppendLine($"{key}"); + } + sourceBuilder.AppendLine(); + + // Generate all used keys + sourceBuilder.AppendLine("usedKeys"); + foreach (var key in usedKeys) + { + sourceBuilder.AppendLine($"{key}"); + }*/ + //sourceBuilder.AppendLine("*/"); + + // Generate class + sourceBuilder.AppendLine($"[System.CodeDom.Compiler.GeneratedCode(\"{nameof(LocalizeSourceGenerator)}\", \"{PackageVersion}\")]"); + sourceBuilder.AppendLine($"public static class {Constants.ClassName}"); + sourceBuilder.AppendLine("{"); + + var tabString = Helper.Spacing(1); + + // Generate API instance + string getTranslation = null; + if (useDI) + { + // Use instance from PublicApiSourceGenerator + getTranslation = $"{assemblyNamespace}.{Constants.PublicApiClassName}.{Constants.PublicApiInternalPropertyName}.GetTranslation"; + } + else if (pluginInfo?.IsValid == true) + { + getTranslation = $"{pluginInfo.ContextAccessor}.API.GetTranslation"; + } + + // Generate localization methods + foreach (var ls in localizedStrings) + { + var isLast = ls.Equals(localizedStrings.Last()); + GenerateDocComments(sourceBuilder, ls, tabString); + GenerateLocalizationMethod(sourceBuilder, ls, getTranslation, tabString, isLast); + } + + sourceBuilder.AppendLine("}"); + + // Add source to context + spc.AddSource($"{Constants.ClassName}.{assemblyNamespace}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + } + + private static void GeneratedHeaderFromPath(StringBuilder sb, string xamlFilePath) + { + if (string.IsNullOrEmpty(xamlFilePath)) + { + sb.AppendLine("/// "); + } + else + { + sb.AppendLine("/// ") + .AppendLine($"/// From: {xamlFilePath}") + .AppendLine("/// "); + } + } + + private static void GenerateDocComments(StringBuilder sb, LocalizableString ls, string tabString) + { + if (!string.IsNullOrEmpty(ls.Summary)) + { + var summaryLines = ls.Summary.Split('\n'); + if (summaryLines.Length > 0) + { + sb.AppendLine($"{tabString}/// "); + foreach (var line in summaryLines) + { + sb.AppendLine($"{tabString}/// {line.Trim()}"); + } + sb.AppendLine($"{tabString}/// "); + } + } + + var lines = ls.Value.Split('\n'); + if (lines.Length > 0) + { + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}/// e.g.: "); + foreach (var line in lines) + { + sb.AppendLine($"{tabString}/// {line.Trim()}"); + } + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}/// "); + } + } + + private static void GenerateLocalizationMethod( + StringBuilder sb, + LocalizableString ls, + string getTranslation, + string tabString, + bool last) + { + sb.Append($"{tabString}public static string {ls.Key}("); + + // Get parameter string + var parameters = ls.Params.ToList(); + sb.Append(string.Join(", ", parameters.Select(p => $"{p.Type} {p.Name}"))); + sb.Append(") => "); + var formatArgs = parameters.Count > 0 + ? $", {string.Join(", ", parameters.Select(p => p.Name))}" + : string.Empty; + + if (!(string.IsNullOrEmpty(getTranslation))) + { + sb.AppendLine(parameters.Count > 0 + ? !ls.Format ? + $"string.Format({getTranslation}(\"{ls.Key}\"){formatArgs});" + : $"string.Format(System.Globalization.CultureInfo.CurrentCulture, {getTranslation}(\"{ls.Key}\"){formatArgs});" + : $"{getTranslation}(\"{ls.Key}\");"); + } + else + { + sb.AppendLine("\"LOCALIZATION_ERROR\";"); + } + + if (!last) + { + sb.AppendLine(); + } + } + + #endregion + + #region Classes + + public class LocalizableStringParam + { + public int Index { get; set; } + public string Format { get; set; } + public string Name { get; set; } + public string Type { get; set; } + } + + public class LocalizableString + { + public string Key { get; } + public string Value { get; } + public string Summary { get; } + public IEnumerable Params { get; } + + public bool Format => Params.Any(p => !string.IsNullOrEmpty(p.Format)); + + public LocalizableString(string key, string value, string summary, IEnumerable @params) + { + Key = key; + Value = value; + Summary = summary; + Params = @params; + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs new file mode 100644 index 00000000000..92593b7522c --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs @@ -0,0 +1,106 @@ +using System; +using System.Text; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.SourceGenerators.Localize +{ + [Generator] + public partial class PublicApiSourceGenerator : IIncrementalGenerator + { + #region Fields + + private static readonly Version PackageVersion = typeof(PublicApiSourceGenerator).Assembly.GetName().Version; + + #endregion + + #region Incremental Generator + + /// + /// Initializes the generator and registers source output based on build property FLLUseDependencyInjection. + /// + /// The initialization context. + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var compilation = context.CompilationProvider; + + var configOptions = context.AnalyzerConfigOptionsProvider; + + var compilationEnums = configOptions.Combine(compilation); + + context.RegisterSourceOutput(compilationEnums, Execute); + } + + /// + /// Executes the generation of public api property based on the provided data. + /// + /// The source production context. + /// The provided data. + private void Execute(SourceProductionContext spc, + (AnalyzerConfigOptionsProvider ConfigOptionsProvider, Compilation Compilation) data) + { + var compilation = data.Compilation; + var configOptions = data.ConfigOptionsProvider; + + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; + var useDI = configOptions.GetFLLUseDependencyInjection(); + + // If we do not use dependency injection, we do not need to generate the public api property + if (!useDI) return; + + GenerateSource(spc, assemblyNamespace); + } + + #endregion + + #region Generate Source + + private void GenerateSource( + SourceProductionContext spc, + string assemblyNamespace) + { + var tabString = Helper.Spacing(1); + + var sourceBuilder = new StringBuilder(); + + // Generate header + GeneratedHeaderFromPath(sourceBuilder); + sourceBuilder.AppendLine(); + + // Generate nullable enable + sourceBuilder.AppendLine("#nullable enable"); + sourceBuilder.AppendLine(); + + // Generate namespace + sourceBuilder.AppendLine($"namespace {assemblyNamespace};"); + sourceBuilder.AppendLine(); + + // Generate class + sourceBuilder.AppendLine($"[System.CodeDom.Compiler.GeneratedCode(\"{nameof(PublicApiSourceGenerator)}\", \"{PackageVersion}\")]"); + sourceBuilder.AppendLine($"internal static class {Constants.PublicApiClassName}"); + sourceBuilder.AppendLine("{"); + + // Generate properties + sourceBuilder.AppendLine($"{tabString}private static Flow.Launcher.Plugin.IPublicAPI? {Constants.PublicApiPrivatePropertyName} = null;"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// Get instance"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}internal static Flow.Launcher.Plugin.IPublicAPI {Constants.PublicApiInternalPropertyName} =>" + + $"{Constants.PublicApiPrivatePropertyName} ??= CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetRequiredService();"); + sourceBuilder.AppendLine($"}}"); + + // Add source to context + spc.AddSource($"{Constants.PublicApiClassName}.{assemblyNamespace}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + } + + private static void GeneratedHeaderFromPath(StringBuilder sb) + { + sb.AppendLine("/// "); + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs new file mode 100644 index 00000000000..a26ee5793e9 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs @@ -0,0 +1,80 @@ +using System.Collections.Immutable; +using System.Linq; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; + +namespace Flow.Launcher.Localization.SourceGenerators +{ + internal class PluginInfoHelper + { + public static PluginClassInfo GetValidPluginInfoAndReportDiagnostic( + ImmutableArray pluginClasses, + SourceProductionContext context) + { + // If p is null, this class does not implement IPluginI18n + var iPluginI18nClasses = pluginClasses.Where(p => p != null).ToArray(); + if (iPluginI18nClasses.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.CouldNotFindPluginEntryClass, + Location.None + )); + return null; + } + + // If p.PropertyName is null, this class does not have PluginInitContext property + var iPluginI18nClassesWithContext = iPluginI18nClasses.Where(p => p.PropertyName != null).ToArray(); + if (iPluginI18nClassesWithContext.Length == 0) + { + foreach (var pluginClass in iPluginI18nClasses) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.CouldNotFindContextProperty, + pluginClass.Location, + pluginClass.ClassName + )); + } + return null; + } + + // Rest classes have implemented IPluginI18n and have PluginInitContext property + // Check if the property is valid + foreach (var pluginClass in iPluginI18nClassesWithContext) + { + if (pluginClass.IsValid == true) + { + return pluginClass; + } + + if (!pluginClass.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyNotStatic, + pluginClass.Location, + pluginClass.PropertyName + )); + } + + if (pluginClass.IsPrivate) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyIsPrivate, + pluginClass.Location, + pluginClass.PropertyName + )); + } + + if (pluginClass.IsProtected) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyIsProtected, + pluginClass.Location, + pluginClass.PropertyName + )); + } + } + + return null; + } + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs new file mode 100644 index 00000000000..f8cde858e8e --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs @@ -0,0 +1,80 @@ +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; + +namespace Flow.Launcher.Localization.SourceGenerators +{ + public static class SourceGeneratorDiagnostics + { + public static readonly DiagnosticDescriptor CouldNotFindResourceDictionaries = new DiagnosticDescriptor( + "FLSG0001", + "Could not find resource dictionaries", + "Could not find resource dictionaries. There must be a `en.xaml` file under `Language` folder.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor CouldNotFindPluginEntryClass = new DiagnosticDescriptor( + "FLSG0002", + "Could not find the main class of plugin", + $"Could not find the main class of your plugin. It must implement `{Constants.PluginInterfaceName}`.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor CouldNotFindContextProperty = new DiagnosticDescriptor( + "FLSG0003", + "Could not find plugin context property", + $"Could not find a property of type `{Constants.PluginContextTypeName}` in `{{0}}`. It must be a public static or internal static property of the main class of your plugin.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextPropertyNotStatic = new DiagnosticDescriptor( + "FLSG0004", + "Plugin context property is not static", + "Context property `{0}` is not static. It must be static.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextPropertyIsPrivate = new DiagnosticDescriptor( + "FLSG0005", + "Plugin context property is private", + "Context property `{0}` is private. It must be either internal or public.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ContextPropertyIsProtected = new DiagnosticDescriptor( + "FLSG0006", + "Plugin context property is protected", + "Context property `{0}` is protected. It must be either internal or public.", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor LocalizationKeyUnused = new DiagnosticDescriptor( + "FLSG0007", + "Localization key is unused", + $"Method `{Constants.ClassName}.{{0}}` is never used", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor EnumFieldLocalizationKeyValueInvalid = new DiagnosticDescriptor( + "FLSG0008", + "Enum field localization key and value invalid", + $"Enum field `{{0}}` does not have a valid localization key or value", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + } +} diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.slnx b/Flow.Launcher.Localization/Flow.Launcher.Localization.slnx new file mode 100644 index 00000000000..779a1ca196c --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj new file mode 100644 index 00000000000..adc3ad72879 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj @@ -0,0 +1,75 @@ + + + + netstandard2.0 + true + Flow.Launcher.Localization + Flow.Launcher.Localization + false + false + true + true + + + + 0.0.3 + 0.0.3 + 0.0.3 + 0.0.3 + Flow.Launcher.Localization + Flow Launcher Localization Toolkit + Localization toolkit for Flow Launcher and its plugins + Flow-Launcher + MIT + https://github.com/Flow-Launcher/Flow.Launcher.Localization + Localization toolkit for Flow Launcher and its plugins + localization-tool; localization-toolkit; flow-launcher; flow-launcher-plugins; flowlauncher; localization-tools; flow-launcher-plugin + true + true + README.md + + + + + All + + + runtime + + + All + + + All + + + + + + true + analyzers/dotnet/cs + false + + + true + lib/$(TargetFramework) + true + + + true + analyzers/dotnet/cs + false + + + true + analyzers/dotnet/cs + false + + + True + \ + + + + + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization/build/Flow.Launcher.Localization.props b/Flow.Launcher.Localization/Flow.Launcher.Localization/build/Flow.Launcher.Localization.props new file mode 100644 index 00000000000..80b3641ded5 --- /dev/null +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization/build/Flow.Launcher.Localization.props @@ -0,0 +1,6 @@ + + + + + + diff --git a/Flow.Launcher.Localization/LICENSE b/Flow.Launcher.Localization/LICENSE new file mode 100644 index 00000000000..49c7c8c7e0a --- /dev/null +++ b/Flow.Launcher.Localization/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2025 The Flow Launcher team + +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. diff --git a/Flow.Launcher.Localization/README.md b/Flow.Launcher.Localization/README.md new file mode 100644 index 00000000000..750e0fb2d21 --- /dev/null +++ b/Flow.Launcher.Localization/README.md @@ -0,0 +1,8 @@ +# Flow Launcher Localization Toolkit + +Localization toolkit for Flow Launcher and its plugins. + +Useful links: + +* [Flow Launcher localization toolkit guide](https://www.flowlauncher.com/docs/#/localization-toolkit) +* [.NET plugin development guide](https://www.flowlauncher.com/docs/#/develop-dotnet-plugins) diff --git a/Flow.Launcher.Localization/global.json b/Flow.Launcher.Localization/global.json new file mode 100644 index 00000000000..36e1a9e95f3 --- /dev/null +++ b/Flow.Launcher.Localization/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/Flow.Launcher.Localization/localization-toolkit.md b/Flow.Launcher.Localization/localization-toolkit.md new file mode 100644 index 00000000000..247ac58d215 --- /dev/null +++ b/Flow.Launcher.Localization/localization-toolkit.md @@ -0,0 +1,118 @@ +The Localization Toolkit helps Flow Launcher C# plugin developers make the localization process easier. + +## Getting Started + +For C# plugins, install and reference [Flow.Launcher.Localization](www.nuget.org/packages/Flow.Launcher.Localization) via NuGet. + +## Build Properties + +These are properties you can configure in your `.csproj` file to customize the localization process. You can set them in the `` section. For example, to set the `FLLUseDependencyInjection` property to `true`, add the following lines: + +```xml + + true + +``` + +### `FLLUseDependencyInjection` + +This flag specifies whether to use dependency injection to obtain an `IPublicAPI` instance. The default is `false`. +- If set to `false`, the Main class (which must implement **[IPlugin](/API-Reference/Flow.Launcher.Plugin/IPlugin.md)** or **[IAsyncPlugin](/API-Reference/Flow.Launcher.Plugin/IAsyncPlugin.md)**) + must have a [PluginInitContext](/API-Reference/Flow.Launcher.Plugin/PluginInitContext.md) property that is at least `internal static`. +- If set to `true`, you can access the `IPublicAPI` instance via `PublicApi.Instance` using dependency injection, and the Main class does not need to include a [PluginInitContext](/API-Reference/Flow.Launcher.Plugin/PluginInitContext.md) property. + (Note: This approach is not recommended for plugin projects at the moment since it limits compatibility to Flow Launcher 1.20.0 or later.) + +## Usage + +### Main Class + +The Main class must implement [IPluginI18n](/API-Reference/Flow.Launcher.Plugin/IPluginI18n.md). + +If `FLLUseDependencyInjection` is `false`, include a [PluginInitContext](/API-Reference/Flow.Launcher.Plugin/PluginInitContext.md) property, for example: + +```csharp + // Must implement IPluginI18n +public class Main : IPlugin, IPluginI18n +{ + // Must be at least internal static + internal static PluginInitContext Context { get; private set; } = null!; +} +``` + +### Localized Strings + +You can simplify your code by replacing calls like: +```csharp +Context.API.GetTranslation("flowlauncher_plugin_localization_demo_plugin_name") +``` +with: +```csharp +Localize.flowlauncher_plugin_localization_demo_plugin_name() +``` + +If your localization string uses variables, it becomes even simpler! From this: +```csharp +string.Format(Context.API.GetTranslation("flowlauncher_plugin_localization_demo_value_with_keys"), firstName, lastName); +``` +To this: +```csharp +Localize.flowlauncher_plugin_localization_demo_value_with_keys(firstName, lastName); +``` + +### Localized Enums + +For enum types (e.g., `DemoEnum`) that need localization in UI controls such as combo boxes, use the `EnumLocalize` attribute to enable localization. For each enum field: +- Use `EnumLocalizeKey` to provide a custom localization key. +- Use `EnumLocalizeValue` to provide a constant localization string. + +Example: + +```csharp +[EnumLocalize] // Enable localization support +public enum DemoEnum +{ + // Specific localization key + [EnumLocalizeKey("localize_key_1")] + Value1, + + // Specific localization value + [EnumLocalizeValue("This is my enum value localization")] + Value2, + + // Key takes precedence if both are present + [EnumLocalizeKey("localize_key_3")] + [EnumLocalizeValue("Localization Value")] + Value3, + + // Using the Localize class. This way, you can't misspell localization keys, and if you rename + // them in your .xaml file, you won't forget to rename them here as well because the build will fail. + [EnumLocalizeKey(nameof(Localize.flowlauncher_plugin_localization_demo_plugin_description))] + Value4, +} +``` + +Then, use the generated `DemoEnumLocalized` class within your view model to bind to a combo box control: + +```csharp +// ComboBox ItemSource +public List AllDemoEnums { get; } = DemoEnumLocalized.GetValues(); + +// ComboBox SelectedValue +public DemoEnum SelectedDemoEnum { get; set; } +``` + +In your XAML, bind as follows: + +```xml + +``` + +To update localization strings when the language changes, you can call: + +```csharp +DemoEnumLocalize.UpdateLabels(AllDemoEnums); +``` diff --git a/Flow.Launcher.sln b/Flow.Launcher.sln index e44b23232fb..67c0f25bca7 100644 --- a/Flow.Launcher.sln +++ b/Flow.Launcher.sln @@ -53,8 +53,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution LICENSE = LICENSE Scripts\post_build.ps1 = Scripts\post_build.ps1 README.md = README.md - SolutionAssemblyInfo.cs = SolutionAssemblyInfo.cs Settings.XamlStyler = Settings.XamlStyler + SolutionAssemblyInfo.cs = SolutionAssemblyInfo.cs EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.Shell", "Plugins\Flow.Launcher.Plugin.Shell\Flow.Launcher.Plugin.Shell.csproj", "{C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}" @@ -71,6 +71,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.Plugin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Flow.Launcher.Plugin.WindowsSettings", "Plugins\Flow.Launcher.Plugin.WindowsSettings\Flow.Launcher.Plugin.WindowsSettings.csproj", "{5043CECE-E6A7-4867-9CBE-02D27D83747A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Flow.Launcher.Localization", "Flow.Launcher.Localization", "{966EEB8B-5109-1020-D93D-9CB99693248E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Localization", "Flow.Launcher.Localization\Flow.Launcher.Localization\Flow.Launcher.Localization.csproj", "{7D495A1A-D861-2434-9A9E-2147A71EF8FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Localization.Analyzers", "Flow.Launcher.Localization\Flow.Launcher.Localization.Analyzers\Flow.Launcher.Localization.Analyzers.csproj", "{FD6DA12A-F468-559A-D973-F2A20494028B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Localization.Attributes", "Flow.Launcher.Localization\Flow.Launcher.Localization.Attributes\Flow.Launcher.Localization.Attributes.csproj", "{5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Localization.Shared", "Flow.Launcher.Localization\Flow.Launcher.Localization.Shared\Flow.Launcher.Localization.Shared.csproj", "{A1A23E38-C825-0C1C-7919-E780289F927F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Localization.SourceGenerators", "Flow.Launcher.Localization\Flow.Launcher.Localization.SourceGenerators\Flow.Launcher.Localization.SourceGenerators.csproj", "{E846BB23-171C-FAD8-8774-437821CD6100}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,8 +93,20 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.Build.0 = Debug|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.Build.0 = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.Build.0 = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.ActiveCfg = Release|Any CPU + {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.Build.0 = Release|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.ActiveCfg = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.Build.0 = Debug|Any CPU {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -105,18 +129,6 @@ Global {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x64.Build.0 = Release|Any CPU {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x86.ActiveCfg = Release|Any CPU {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x86.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x64.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Debug|x86.Build.0 = Debug|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|Any CPU.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x64.Build.0 = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.ActiveCfg = Release|Any CPU - {DB90F671-D861-46BB-93A3-F1304F5BA1C5}.Release|x86.Build.0 = Release|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -286,6 +298,66 @@ Global {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x64.Build.0 = Release|Any CPU {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x86.ActiveCfg = Release|Any CPU {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x86.Build.0 = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|x64.Build.0 = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Debug|x86.Build.0 = Debug|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|Any CPU.Build.0 = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|x64.ActiveCfg = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|x64.Build.0 = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|x86.ActiveCfg = Release|Any CPU + {7D495A1A-D861-2434-9A9E-2147A71EF8FD}.Release|x86.Build.0 = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|x64.Build.0 = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Debug|x86.Build.0 = Debug|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|Any CPU.Build.0 = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|x64.ActiveCfg = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|x64.Build.0 = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|x86.ActiveCfg = Release|Any CPU + {FD6DA12A-F468-559A-D973-F2A20494028B}.Release|x86.Build.0 = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|x64.ActiveCfg = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|x64.Build.0 = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Debug|x86.Build.0 = Debug|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|Any CPU.Build.0 = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|x64.ActiveCfg = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|x64.Build.0 = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|x86.ActiveCfg = Release|Any CPU + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3}.Release|x86.Build.0 = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|x64.Build.0 = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Debug|x86.Build.0 = Debug|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|Any CPU.Build.0 = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|x64.ActiveCfg = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|x64.Build.0 = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|x86.ActiveCfg = Release|Any CPU + {A1A23E38-C825-0C1C-7919-E780289F927F}.Release|x86.Build.0 = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|x64.ActiveCfg = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|x64.Build.0 = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|x86.ActiveCfg = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Debug|x86.Build.0 = Debug|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|Any CPU.Build.0 = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|x64.ActiveCfg = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|x64.Build.0 = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|x86.ActiveCfg = Release|Any CPU + {E846BB23-171C-FAD8-8774-437821CD6100}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -303,6 +375,11 @@ Global {588088F4-3262-4F9F-9663-A05DE12534C3} = {3A73F5A7-0335-40D8-BF7C-F20BE5D0BA87} {4792A74A-0CEA-4173-A8B2-30E6764C6217} = {3A73F5A7-0335-40D8-BF7C-F20BE5D0BA87} {5043CECE-E6A7-4867-9CBE-02D27D83747A} = {3A73F5A7-0335-40D8-BF7C-F20BE5D0BA87} + {7D495A1A-D861-2434-9A9E-2147A71EF8FD} = {966EEB8B-5109-1020-D93D-9CB99693248E} + {FD6DA12A-F468-559A-D973-F2A20494028B} = {966EEB8B-5109-1020-D93D-9CB99693248E} + {5F306065-2D83-4FBE-2DDD-3BAF37B77BF3} = {966EEB8B-5109-1020-D93D-9CB99693248E} + {A1A23E38-C825-0C1C-7919-E780289F927F} = {966EEB8B-5109-1020-D93D-9CB99693248E} + {E846BB23-171C-FAD8-8774-437821CD6100} = {966EEB8B-5109-1020-D93D-9CB99693248E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F26ACB50-3F6C-4907-B0C9-1ADACC1D0DED} diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj b/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj index b444fb8f655..81e1e648327 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj @@ -53,7 +53,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs index fbaefa9d66e..6aa20186039 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Plugin.Explorer.Helper; +using Flow.Launcher.Plugin.Explorer.Helper; using Flow.Launcher.Plugin.Explorer.Search; using Flow.Launcher.Plugin.Explorer.Search.Everything; using Flow.Launcher.Plugin.Explorer.ViewModels; @@ -92,12 +92,12 @@ public async Task> QueryAsync(Query query, CancellationToken token) public string GetTranslatedPluginTitle() { - return Context.API.GetTranslation("plugin_explorer_plugin_name"); + return Localize.plugin_explorer_plugin_name(); } public string GetTranslatedPluginDescription() { - return Context.API.GetTranslation("plugin_explorer_plugin_description"); + return Localize.plugin_explorer_plugin_description(); } private static void FillQuickAccessLinkNames()