Skip to content

Commit f469ef5

Browse files
committed
fix(issue-6): round-4 fixes + cross-surface coverage (CodeLens, Add-Key, CLI, TUI, sync)
VS Code extension: - CodeLens for code files now uses the backend scan (cacheService.scanFile) instead of client-side regex, so namespace/using segments are no longer flagged as keys and value hints appear for any injected localizer (Q, Loc, ...), not only L. - Add-Key dialog gained a per-language value matrix (default marked) and a default-empty-value warning; buildAddKeyMessage takes a per-language map. API (web/VS Code backend): - ResourcesController.AddKey routes the "default"-keyed value to the suffix-less default file regardless of configured DefaultLanguageCode (ResolveForFile). CLI: - `lrm add --lang default:value` resolves to the default file via IsDefault, so it works when DefaultLanguageCode (e.g. "it") is configured. TUI: - AddNewKey writes via MergedLanguageColumns winner so a default-vs-culture collision reliably routes the value to the default file. Sync: - FileRegenerator creates new language files in the group's own subfolder instead of the project root. Tests: - New Issue6Scenario fixture project + integration tests (subfolder discovery, default-code collision, nested namespace, @Inject localizers, multi-group Add-Key). - CLI language-resolution tests, TUI add-key routing tests, sync new-language-in-subfolder + nested-group push/pull round-trip tests, CodeLens unit tests, Add-Key message builder tests.
1 parent 5953e00 commit f469ef5

25 files changed

Lines changed: 1231 additions & 104 deletions

File tree

Commands/AddCommand.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,10 @@ private int ExecuteSimpleAdd(AddCommandSettings settings, List<LanguageInfo> lan
164164
var code = parts[0].Trim();
165165
var value = parts[1];
166166

167-
// Normalize "default" alias to empty string
168-
if (code.Equals("default", StringComparison.OrdinalIgnoreCase))
169-
{
170-
code = "";
171-
}
172-
173-
// Validate language code exists
174-
var matchingLang = languages.FirstOrDefault(l => l.Code.Equals(code, StringComparison.OrdinalIgnoreCase));
167+
var matchingLang = ResolveLanguageForCode(languages, code);
175168
if (matchingLang == null)
176169
{
177-
var availableCodes = string.Join(", ", languages.Select(l => l.Code));
170+
var availableCodes = string.Join(", ", languages.Select(l => string.IsNullOrEmpty(l.Code) ? "default" : l.Code));
178171
AnsiConsole.MarkupLine($"[red]✗ Unknown language code: '{code}'[/]");
179172
AnsiConsole.MarkupLine($"[yellow]Available languages: {availableCodes}[/]");
180173
return 1;
@@ -235,6 +228,22 @@ private int ExecuteSimpleAdd(AddCommandSettings settings, List<LanguageInfo> lan
235228
});
236229
}
237230

231+
/// <summary>
232+
/// Resolves a <c>--lang code:value</c> code to a discovered language. The "default"
233+
/// alias maps to the default file regardless of its actual code: that code may be
234+
/// blank, or a configured DefaultLanguageCode such as "it", so resolving by IsDefault
235+
/// (rather than assuming "") keeps <c>--lang default:value</c> working in both cases
236+
/// (issue #6). Returns null when no language matches.
237+
/// </summary>
238+
internal static LanguageInfo? ResolveLanguageForCode(List<LanguageInfo> languages, string code)
239+
{
240+
if (code.Equals("default", StringComparison.OrdinalIgnoreCase))
241+
{
242+
return languages.FirstOrDefault(l => l.IsDefault);
243+
}
244+
return languages.FirstOrDefault(l => l.Code.Equals(code, StringComparison.OrdinalIgnoreCase));
245+
}
246+
238247
private int ExecutePluralAdd(AddCommandSettings settings, List<LanguageInfo> languages, List<ResourceFile> resourceFiles, string resourcePath)
239248
{
240249
// Collect plural forms for each language

Controllers/ResourcesController.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,12 +324,10 @@ public ActionResult<OperationResponse> AddKey([FromBody] AddKeyRequest request)
324324
// Add the key to all resource files in this group
325325
foreach (var resourceFile in resourceFiles)
326326
{
327-
var langCode = resourceFile.Language.Code ?? "default";
328-
329327
if (request.IsPlural && request.PluralValues != null)
330328
{
331329
// Add plural key
332-
var pluralForms = request.PluralValues.GetValueOrDefault(langCode)
330+
var pluralForms = ResolveForFile(request.PluralValues, resourceFile.Language)
333331
?? new Dictionary<string, string> { ["other"] = "" };
334332

335333
resourceFile.Entries.Add(new ResourceEntry
@@ -344,7 +342,7 @@ public ActionResult<OperationResponse> AddKey([FromBody] AddKeyRequest request)
344342
else
345343
{
346344
// Add simple key
347-
var value = request.Values?.GetValueOrDefault(langCode) ?? string.Empty;
345+
var value = ResolveForFile(request.Values, resourceFile.Language) ?? string.Empty;
348346

349347
resourceFile.Entries.Add(new ResourceEntry
350348
{
@@ -369,6 +367,27 @@ public ActionResult<OperationResponse> AddKey([FromBody] AddKeyRequest request)
369367
}
370368
}
371369

370+
/// <summary>
371+
/// Resolves the per-language value to write into a given file from a
372+
/// language-keyed payload. The Add-Key webview sends the default value under
373+
/// the "default" key, while explicit cultures are keyed by their code. The
374+
/// default file may have a blank code or an explicit DefaultLanguageCode
375+
/// (e.g. "it"), so we try, in order: the file's effective display code, its
376+
/// raw code, and — for the default file only — the literal "default" key.
377+
/// </summary>
378+
private static TValue? ResolveForFile<TValue>(
379+
Dictionary<string, TValue>? values,
380+
Core.Models.LanguageInfo language)
381+
{
382+
if (values == null) return default;
383+
384+
if (values.TryGetValue(language.GetDisplayCode(), out var byDisplay)) return byDisplay;
385+
if (!string.IsNullOrEmpty(language.Code) && values.TryGetValue(language.Code, out var byCode)) return byCode;
386+
if (language.IsDefault && values.TryGetValue("default", out var byDefault)) return byDefault;
387+
388+
return default;
389+
}
390+
372391
/// <summary>
373392
/// Update an existing key within a specific resource group (supports occurrence
374393
/// parameter for duplicates). When the directory has exactly one group,

LocalizationManager.Core/Cloud/FileRegenerator.cs

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,21 @@ public async Task<RegenerationResult> RegenerateFilesAsync(
4545

4646
// Lookup for existing language files keyed by (BaseName, Code).
4747
var existingLangFiles = new Dictionary<(string BaseName, string Code), LanguageInfo>();
48+
// Directory each resource group lives in, so a NEW language file for that group is
49+
// created alongside its siblings (e.g. Resources/Components/Account/Pages/) instead
50+
// of at the project root (issue #6: subfolder groups).
51+
var groupDirectories = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
4852
foreach (var lang in existingLanguages)
4953
{
5054
var key = (lang.BaseName ?? string.Empty, lang.Code);
5155
existingLangFiles[key] = lang;
56+
57+
var bn = lang.BaseName ?? string.Empty;
58+
if (!groupDirectories.ContainsKey(bn) && !string.IsNullOrEmpty(lang.FilePath))
59+
{
60+
var dir = Path.GetDirectoryName(lang.FilePath);
61+
if (!string.IsNullOrEmpty(dir)) groupDirectories[bn] = dir;
62+
}
5263
}
5364

5465
// Check if the backend uses explicit language codes for the default language
@@ -93,13 +104,15 @@ await UpdateExistingFileAsync(
93104
}
94105
else
95106
{
96-
// Create new language file
107+
// Create new language file alongside the group's existing files.
108+
groupDirectories.TryGetValue(baseName, out var groupDir);
97109
await CreateNewLanguageFileAsync(
98110
baseName,
99111
resolvedLang,
100112
entries,
101113
tempDir,
102114
result,
115+
groupDir,
103116
cancellationToken);
104117
}
105118
}
@@ -232,10 +245,12 @@ private async Task CreateNewLanguageFileAsync(
232245
List<MergedEntry> entries,
233246
string tempDir,
234247
RegenerationResult result,
248+
string? groupDirectory,
235249
CancellationToken cancellationToken)
236250
{
237-
// Determine file path for new language, honoring the resource group's base name.
238-
var filePath = GetNewLanguageFilePath(baseName, lang);
251+
// Determine file path for new language, honoring the resource group's base name
252+
// and the directory the group lives in (so subfolder groups stay in their folder).
253+
var filePath = GetNewLanguageFilePath(baseName, lang, groupDirectory);
239254

240255
// Create language info for new file
241256
var languageInfo = new LanguageInfo
@@ -357,12 +372,16 @@ private string GetRelativeFilePath(string? filePath, string baseDirectory)
357372
/// and the resource group's base name. Empty <paramref name="baseName"/>
358373
/// falls back to the historical default naming.
359374
/// </summary>
360-
private string GetNewLanguageFilePath(string baseName, string lang)
375+
private string GetNewLanguageFilePath(string baseName, string lang, string? groupDirectory = null)
361376
{
362377
// Get path convention from backend
363378
var backendName = _backend.Name.ToLowerInvariant();
364379
var isDefaultLang = string.IsNullOrEmpty(lang);
365380

381+
// Place the new file in the group's own directory when known (preserves subfolder
382+
// layout, e.g. Resources/Components/Account/Pages/), else at the project root.
383+
var baseDir = string.IsNullOrEmpty(groupDirectory) ? _projectDirectory : groupDirectory;
384+
366385
// For backends where the file name embeds the base name (resx, json,
367386
// xliff), use the base name as the file root; otherwise fall back to
368387
// the conventional defaults.
@@ -374,29 +393,29 @@ private string GetNewLanguageFilePath(string baseName, string lang)
374393
return backendName switch
375394
{
376395
"resx" => isDefaultLang
377-
? Path.Combine(_projectDirectory, $"{resxRoot}.resx")
378-
: Path.Combine(_projectDirectory, $"{resxRoot}.{lang}.resx"),
396+
? Path.Combine(baseDir, $"{resxRoot}.resx")
397+
: Path.Combine(baseDir, $"{resxRoot}.{lang}.resx"),
379398
"json" or "jsonlocalization" => isDefaultLang
380-
? Path.Combine(_projectDirectory, $"{jsonRoot}.json")
381-
: Path.Combine(_projectDirectory, $"{jsonRoot}.{lang}.json"),
399+
? Path.Combine(baseDir, $"{jsonRoot}.json")
400+
: Path.Combine(baseDir, $"{jsonRoot}.{lang}.json"),
382401
"android" => isDefaultLang
383-
? Path.Combine(_projectDirectory, "values", "strings.xml")
384-
: Path.Combine(_projectDirectory, $"values-{lang}", "strings.xml"),
402+
? Path.Combine(baseDir, "values", "strings.xml")
403+
: Path.Combine(baseDir, $"values-{lang}", "strings.xml"),
385404
"ios" or "strings" => isDefaultLang
386-
? Path.Combine(_projectDirectory, "en.lproj", "Localizable.strings")
387-
: Path.Combine(_projectDirectory, $"{lang}.lproj", "Localizable.strings"),
405+
? Path.Combine(baseDir, "en.lproj", "Localizable.strings")
406+
: Path.Combine(baseDir, $"{lang}.lproj", "Localizable.strings"),
388407
"i18next" => isDefaultLang
389-
? Path.Combine(_projectDirectory, "en.json")
390-
: Path.Combine(_projectDirectory, $"{lang}.json"),
408+
? Path.Combine(baseDir, "en.json")
409+
: Path.Combine(baseDir, $"{lang}.json"),
391410
"xliff" => isDefaultLang
392-
? Path.Combine(_projectDirectory, $"{xlfRoot}.xlf")
393-
: Path.Combine(_projectDirectory, $"{xlfRoot}.{lang}.xlf"),
411+
? Path.Combine(baseDir, $"{xlfRoot}.xlf")
412+
: Path.Combine(baseDir, $"{xlfRoot}.{lang}.xlf"),
394413
"po" or "gettext" => isDefaultLang
395-
? Path.Combine(_projectDirectory, $"{poRoot}.pot")
396-
: Path.Combine(_projectDirectory, $"{lang}.po"),
414+
? Path.Combine(baseDir, $"{poRoot}.pot")
415+
: Path.Combine(baseDir, $"{lang}.po"),
397416
_ => isDefaultLang
398-
? Path.Combine(_projectDirectory, $"strings.{backendName}")
399-
: Path.Combine(_projectDirectory, $"strings.{lang}.{backendName}")
417+
? Path.Combine(baseDir, $"strings.{backendName}")
418+
: Path.Combine(baseDir, $"strings.{lang}.{backendName}")
400419
};
401420
}
402421

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using LocalizationManager.Commands;
5+
using LocalizationManager.Core.Backends.Resx;
6+
using LocalizationManager.Core.Models;
7+
using Xunit;
8+
9+
namespace LocalizationManager.Tests.IntegrationTests;
10+
11+
/// <summary>
12+
/// Tests for the CLI `lrm add --lang code:value` language resolution (issue #6).
13+
/// The "default" alias must target the suffix-less default file whether its code is
14+
/// blank (no DefaultLanguageCode configured) or a configured code such as "it".
15+
/// Driven from real discovery over a temp resource directory.
16+
/// </summary>
17+
public class AddCommandLanguageResolutionTests : IDisposable
18+
{
19+
private readonly string _dir;
20+
21+
public AddCommandLanguageResolutionTests()
22+
{
23+
_dir = Path.Combine(Path.GetTempPath(), $"LrmAddLang_{Guid.NewGuid():N}");
24+
Directory.CreateDirectory(_dir);
25+
}
26+
27+
public void Dispose()
28+
{
29+
if (Directory.Exists(_dir)) Directory.Delete(_dir, true);
30+
}
31+
32+
private void WriteResx(string fileName, params (string Key, string Value)[] entries)
33+
{
34+
var body = string.Join("\n", entries.Select(e =>
35+
$" <data name=\"{e.Key}\"><value>{e.Value}</value></data>"));
36+
File.WriteAllText(Path.Combine(_dir, fileName),
37+
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root>\n" +
38+
" <resheader name=\"resmimetype\"><value>text/microsoft-resx</value></resheader>\n" +
39+
body + "\n</root>\n");
40+
}
41+
42+
[Fact]
43+
public void Default_NoConfiguredCode_ResolvesToBlankCodeDefaultFile()
44+
{
45+
WriteResx("Res.resx", ("Hi", "Hi"));
46+
WriteResx("Res.it.resx", ("Hi", "Ciao"));
47+
var languages = new ResxResourceDiscovery().DiscoverLanguages(_dir);
48+
49+
var match = AddCommand.ResolveLanguageForCode(languages, "default");
50+
51+
Assert.NotNull(match);
52+
Assert.True(match!.IsDefault);
53+
Assert.Equal("", match.Code);
54+
}
55+
56+
[Fact]
57+
public void Default_ConfiguredItCode_ResolvesToDefaultFileLabeledIt()
58+
{
59+
// DefaultLanguageCode = "it": the default file is labeled "it" and there is also
60+
// an explicit Res.it.resx. "default" must still resolve to the default file.
61+
WriteResx("Res.resx", ("Hi", "Ciao"));
62+
WriteResx("Res.it.resx", ("Hi", "CiaoCulture"));
63+
var languages = new ResxResourceDiscovery("it").DiscoverLanguages(_dir);
64+
65+
var match = AddCommand.ResolveLanguageForCode(languages, "default");
66+
67+
Assert.NotNull(match);
68+
Assert.True(match!.IsDefault, "'default' must map to the default file");
69+
Assert.EndsWith("Res.resx", match.FilePath);
70+
Assert.DoesNotContain(".it.resx", match.FilePath);
71+
}
72+
73+
[Fact]
74+
public void ExplicitCultureCode_ResolvesToThatCultureFile()
75+
{
76+
WriteResx("Res.resx", ("Hi", "Ciao"));
77+
WriteResx("Res.el.resx", ("Hi", "Γεια"));
78+
var languages = new ResxResourceDiscovery("it").DiscoverLanguages(_dir);
79+
80+
var match = AddCommand.ResolveLanguageForCode(languages, "el");
81+
82+
Assert.NotNull(match);
83+
Assert.False(match!.IsDefault);
84+
Assert.Equal("el", match.Code);
85+
}
86+
87+
[Fact]
88+
public void UnknownCode_ReturnsNull()
89+
{
90+
WriteResx("Res.resx", ("Hi", "Ciao"));
91+
var languages = new ResxResourceDiscovery("it").DiscoverLanguages(_dir);
92+
93+
Assert.Null(AddCommand.ResolveLanguageForCode(languages, "zz"));
94+
}
95+
}

0 commit comments

Comments
 (0)