Skip to content

Commit 7ccf967

Browse files
committed
Fix default-language, recursive discovery, multi-group scanning and VS Code reference paths
Addresses four issues reported on #6 after the multi-base resource group fix: - RESX/JSON discovery now honor defaultLanguageCode for the suffix-less default file instead of reporting an empty code, so every client labels it as the real language. - RESX/JSON discovery recurse into subfolders (excluding .lrm/.backups/bin/ obj/.git/node_modules) and group subfolder-aware, so nested resource files are discovered (and uploaded on cloud push). - CodeScanner unions keys across all default files, fixing false missing-key diagnostics in multi-group projects. - VS Code extension: reference links use data attributes + Uri.file so Windows paths are no longer corrupted, and the dashboard/status bar show the configured default language instead of hardcoded "English (Default)". Test fixtures moved into FlatResx/ and JsonResourcesI18next/ so recursive discovery no longer sweeps unrelated nested fixtures into flat-layout tests.
1 parent 8940564 commit 7ccf967

22 files changed

Lines changed: 428 additions & 59 deletions

LocalizationManager.Core/Backends/Json/JsonResourceBackend.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ public bool CanHandle(string path)
9999
if (!Directory.Exists(path))
100100
return false;
101101

102-
// Check for JSON files, excluding lrm*.json config files
103-
return Directory.GetFiles(path, "*.json", SearchOption.TopDirectoryOnly)
102+
// Check for JSON files (including nested subfolders), excluding lrm*.json config files
103+
return Directory.EnumerateFiles(path, "*.json", SearchOption.AllDirectories)
104104
.Any(f => !Path.GetFileName(f).StartsWith("lrm", StringComparison.OrdinalIgnoreCase));
105105
}
106106
}

LocalizationManager.Core/Backends/Json/JsonResourceDiscovery.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ public List<LanguageInfo> DiscoverLanguages(string searchPath)
5252
var config = _config ?? LoadConfigFromPath(searchPath);
5353
var defaultLanguageCode = LoadDefaultLanguageFromPath(searchPath);
5454

55-
var jsonFiles = Directory.GetFiles(searchPath, "*.json", SearchOption.TopDirectoryOnly)
55+
// Recurse so JSON files in nested subfolders of the resource path are discovered.
56+
// Skip config files (lrm*.json) and well-known non-source directories.
57+
var jsonFiles = Directory.GetFiles(searchPath, "*.json", SearchOption.AllDirectories)
5658
.Where(f => !Path.GetFileName(f).StartsWith("lrm", StringComparison.OrdinalIgnoreCase))
59+
.Where(f => !IsInExcludedDirectory(f, searchPath))
5760
.ToList();
5861

5962
if (!jsonFiles.Any())
@@ -135,15 +138,23 @@ private List<LanguageInfo> DiscoverStandardFiles(List<string> jsonFiles, string?
135138
// 1. It has no culture code (e.g., strings.json), OR
136139
// 2. Its culture code matches the defaultLanguageCode from lrm.json
137140
var cultureCode = file!.Value.CultureCode;
138-
var isDefault = string.IsNullOrEmpty(cultureCode) ||
141+
var isSuffixless = string.IsNullOrEmpty(cultureCode);
142+
var isDefault = isSuffixless ||
139143
(!string.IsNullOrEmpty(configDefaultLanguage) &&
140144
cultureCode.Equals(configDefaultLanguage, StringComparison.OrdinalIgnoreCase));
145+
146+
// A suffix-less default file (e.g. strings.json) adopts the configured
147+
// default language code so clients label it as the real language rather
148+
// than as an unnamed default.
149+
var effectiveCode = isSuffixless && !string.IsNullOrEmpty(configDefaultLanguage)
150+
? configDefaultLanguage!
151+
: cultureCode;
141152
var displayName = isDefault ? "Default" : GetCultureDisplayName(cultureCode);
142153

143154
result.Add(new LanguageInfo
144155
{
145156
BaseName = file.Value.BaseName,
146-
Code = cultureCode,
157+
Code = effectiveCode,
147158
Name = displayName,
148159
IsDefault = isDefault,
149160
FilePath = file.Value.FilePath
@@ -373,6 +384,23 @@ private string GetCultureDisplayName(string code)
373384
}
374385
}
375386

387+
/// <summary>
388+
/// Directories that should never be treated as resource sources when recursing.
389+
/// </summary>
390+
private static readonly string[] ExcludedDirectories =
391+
{ ".lrm", ".backups", "bin", "obj", ".git", "node_modules" };
392+
393+
/// <summary>
394+
/// Returns true if the file lives under an excluded directory relative to the search root.
395+
/// </summary>
396+
private static bool IsInExcludedDirectory(string filePath, string searchPath)
397+
{
398+
var relative = Path.GetRelativePath(searchPath, filePath);
399+
var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
400+
return segments.Take(segments.Length - 1)
401+
.Any(seg => ExcludedDirectories.Contains(seg, StringComparer.OrdinalIgnoreCase));
402+
}
403+
376404
/// <inheritdoc />
377405
public Task<List<LanguageInfo>> DiscoverLanguagesAsync(string searchPath, CancellationToken ct = default)
378406
=> Task.FromResult(DiscoverLanguages(searchPath));

LocalizationManager.Core/Backends/ResourceBackendFactory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public IResourceBackend GetBackend(string name, ConfigurationModel? config)
5959

6060
return lowerName switch
6161
{
62-
"resx" => new ResxResourceBackend(),
62+
"resx" => new ResxResourceBackend(config?.DefaultLanguageCode),
6363
"json" or "jsonlocalization" => new JsonResourceBackend(config?.Json),
6464
"i18next" => new JsonResourceBackend(config?.Json ?? new Configuration.JsonFormatConfiguration { I18nextCompatible = true }),
6565
"android" => new AndroidResourceBackend(

LocalizationManager.Core/Backends/Resx/ResxResourceBackend.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,16 @@ public class ResxResourceBackend : IResourceBackend
4747
/// <inheritdoc />
4848
public IResourceValidator Validator { get; }
4949

50-
public ResxResourceBackend()
50+
/// <summary>
51+
/// Creates a RESX backend.
52+
/// </summary>
53+
/// <param name="defaultLanguageCode">
54+
/// The default/source language code from configuration (e.g. "it"). Used to label the
55+
/// suffix-less default file with the real language code instead of an empty string.
56+
/// </param>
57+
public ResxResourceBackend(string? defaultLanguageCode = null)
5158
{
52-
Discovery = new ResxResourceDiscovery();
59+
Discovery = new ResxResourceDiscovery(defaultLanguageCode);
5360
Reader = new ResxResourceReader();
5461
Writer = new ResxResourceWriter();
5562
Validator = new ResxResourceValidator();
@@ -61,6 +68,7 @@ public bool CanHandle(string path)
6168
if (!Directory.Exists(path))
6269
return false;
6370

64-
return Directory.GetFiles(path, "*.resx", SearchOption.TopDirectoryOnly).Any();
71+
// Recurse: a project may keep its .resx files only in subfolders.
72+
return Directory.EnumerateFiles(path, "*.resx", SearchOption.AllDirectories).Any();
6573
}
6674
}

LocalizationManager.Core/Backends/Resx/ResxResourceDiscovery.cs

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ namespace LocalizationManager.Core.Backends.Resx;
3131
/// </summary>
3232
public class ResxResourceDiscovery : IResourceDiscovery
3333
{
34+
private readonly string? _defaultLanguageCode;
35+
36+
/// <summary>
37+
/// Creates a RESX discovery instance.
38+
/// </summary>
39+
/// <param name="defaultLanguageCode">
40+
/// The default/source language code from configuration (e.g. "it"). When set, the
41+
/// suffix-less default file (e.g. CustomerResources.resx) is reported with this code
42+
/// instead of an empty string, so every client labels it as the real language rather
43+
/// than guessing "English". When null, the default file keeps an empty code.
44+
/// </param>
45+
public ResxResourceDiscovery(string? defaultLanguageCode = null)
46+
{
47+
_defaultLanguageCode = string.IsNullOrWhiteSpace(defaultLanguageCode) ? null : defaultLanguageCode;
48+
}
49+
3450
/// <inheritdoc />
3551
public List<LanguageInfo> DiscoverLanguages(string searchPath)
3652
{
@@ -41,24 +57,29 @@ public List<LanguageInfo> DiscoverLanguages(string searchPath)
4157

4258
var languages = new List<LanguageInfo>();
4359

44-
// Find all .resx files (exclude .Designer.cs files)
45-
var resxFiles = Directory.GetFiles(searchPath, "*.resx", SearchOption.TopDirectoryOnly)
60+
// Find all .resx files recursively (exclude .Designer.cs files and well-known
61+
// non-source directories like backups/build output). Files may live in nested
62+
// subfolders of the resource path (e.g. Resources/Components/.../Login.it.resx).
63+
var resxFiles = Directory.GetFiles(searchPath, "*.resx", SearchOption.AllDirectories)
4664
.Where(f => !f.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase))
65+
.Where(f => !IsInExcludedDirectory(f, searchPath))
4766
.ToList();
4867

4968
if (!resxFiles.Any())
5069
{
5170
return languages;
5271
}
5372

54-
// Group by base name (e.g., "SharedResource" from "SharedResource.resx" and "SharedResource.el.resx")
73+
// Group by directory + base name so unrelated files that share a base name in
74+
// different subfolders (e.g. Customers/Resources.resx and Glass/Resources.resx)
75+
// are kept as separate resource groups rather than merged.
5576
var groups = resxFiles
56-
.GroupBy(f => GetBaseName(f))
77+
.GroupBy(f => GetGroupKey(f))
5778
.ToList();
5879

5980
foreach (var group in groups)
6081
{
61-
var baseName = group.Key;
82+
var baseName = GetBaseName(group.First());
6283

6384
foreach (var file in group)
6485
{
@@ -67,12 +88,13 @@ public List<LanguageInfo> DiscoverLanguages(string searchPath)
6788

6889
if (parts.Length == 1)
6990
{
70-
// Default language (e.g., SharedResource.resx)
91+
// Default language (e.g., SharedResource.resx). Adopt the configured
92+
// default language code when available; otherwise leave it blank.
7193
languages.Add(new LanguageInfo
7294
{
7395
BaseName = baseName,
74-
Code = "",
75-
Name = "Default",
96+
Code = _defaultLanguageCode ?? "",
97+
Name = _defaultLanguageCode != null ? GetCultureName(_defaultLanguageCode) : "Default",
7698
IsDefault = true,
7799
FilePath = file
78100
});
@@ -116,6 +138,35 @@ private static string GetBaseName(string filePath)
116138
return fileName.Split('.')[0];
117139
}
118140

141+
/// <summary>
142+
/// Builds a grouping key from the containing directory and base name, so that files
143+
/// sharing a base name in different subfolders form separate resource groups.
144+
/// </summary>
145+
private static string GetGroupKey(string filePath)
146+
{
147+
var directory = Path.GetDirectoryName(filePath) ?? string.Empty;
148+
return $"{directory}{Path.DirectorySeparatorChar}{GetBaseName(filePath)}";
149+
}
150+
151+
/// <summary>
152+
/// Directories that should never be treated as resource sources when recursing
153+
/// (backups, build output, VCS and package directories).
154+
/// </summary>
155+
private static readonly string[] ExcludedDirectories =
156+
{ ".lrm", ".backups", "bin", "obj", ".git", "node_modules" };
157+
158+
/// <summary>
159+
/// Returns true if the file lives under an excluded directory relative to the search root.
160+
/// </summary>
161+
private static bool IsInExcludedDirectory(string filePath, string searchPath)
162+
{
163+
var relative = Path.GetRelativePath(searchPath, filePath);
164+
var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
165+
// All segments except the final file name are directory names.
166+
return segments.Take(segments.Length - 1)
167+
.Any(seg => ExcludedDirectories.Contains(seg, StringComparer.OrdinalIgnoreCase));
168+
}
169+
119170
/// <summary>
120171
/// Gets the display name for a culture code using .NET CultureInfo.
121172
/// </summary>

LocalizationManager.Core/Scanning/CodeScanner.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,19 @@ private List<KeyReference> ScanFiles(
394394

395395
private HashSet<string> GetAllResourceKeys(List<ResourceFile> resourceFiles)
396396
{
397-
// Get keys from default language file
398-
var defaultFile = resourceFiles.FirstOrDefault(f => f.Language.IsDefault);
399-
400-
if (defaultFile == null)
401-
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
402-
403-
return new HashSet<string>(defaultFile.Entries.Select(e => e.Key), StringComparer.OrdinalIgnoreCase);
397+
// Union the keys from every default language file. In multi-group projects
398+
// (e.g. CustomerResources, GlassResources, SharedResources) each base resource
399+
// has its own default file, so taking only the first one would flag keys defined
400+
// in the other groups as "missing" even though they exist.
401+
var defaultFiles = resourceFiles.Where(f => f.Language.IsDefault).ToList();
402+
403+
// Fall back to all files if none are explicitly marked default (defensive).
404+
if (defaultFiles.Count == 0)
405+
defaultFiles = resourceFiles;
406+
407+
return new HashSet<string>(
408+
defaultFiles.SelectMany(f => f.Entries.Select(e => e.Key)),
409+
StringComparer.OrdinalIgnoreCase);
404410
}
405411

406412
private List<string> GetLanguagesForKey(List<ResourceFile> resourceFiles, string key)

LocalizationManager.Tests/IntegrationTests/ScanCommandIntegrationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public ScanCommandIntegrationTests()
3232

3333
// Copy TestResource.resx to temp directory
3434
var testDataPath = Path.Combine(Directory.GetCurrentDirectory(), "TestData");
35-
var sourceResxPath = Path.Combine(testDataPath, "TestResource.resx");
35+
var sourceResxPath = Path.Combine(testDataPath, "FlatResx", "TestResource.resx");
3636

3737
if (File.Exists(sourceResxPath))
3838
{

LocalizationManager.Tests/IntegrationTests/ViewCommandIntegrationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public class ViewCommandIntegrationTests
1818
public ViewCommandIntegrationTests()
1919
{
2020
// Use persistent TestData folder
21-
_testDirectory = Path.Combine(AppContext.BaseDirectory, "TestData");
21+
_testDirectory = Path.Combine(AppContext.BaseDirectory, "TestData", "FlatResx");
2222

2323
// Using _reader and _writer initialized above
2424
// Using _discovery initialized above

LocalizationManager.Tests/TestData/TestResource.el.resx renamed to LocalizationManager.Tests/TestData/FlatResx/TestResource.el.resx

File renamed without changes.

LocalizationManager.Tests/TestData/TestResource.fr.resx renamed to LocalizationManager.Tests/TestData/FlatResx/TestResource.fr.resx

File renamed without changes.

0 commit comments

Comments
 (0)