Skip to content

Commit ef9f6ee

Browse files
author
Nikolaos Protopapas
committed
Add multi-base resource group support (closes #6)
Treat the base name of a .resx/.json/etc file (e.g. CustomerResources vs SharedResources) as a first-class disambiguator instead of conflating it with "language." Multi-base directories no longer show duplicate columns, silently drop keys, or report nonsensical coverage. Backend: - New ResourceGroup / ResourceDirectory models + DiscoverResourceGroups default-interface method on IResourceDiscovery - LanguageController, ResourcesController, StatsController, ValidationController, ExportController, ImportController, TranslationController, MergeDuplicatesController, SearchController all aggregate or scope by resource group; AddLanguage/RemoveLanguage operate across every group - Key edit endpoints take a ResourceGroup parameter; defaults to the only group when single-group, required when multi-group Local cloud sync: - LocalEntry / MergedEntry / EntryChange / EntryDeletion / EntryConflict carry BaseName; KeyLevelMerger keys by (BaseName, Key, Lang) - FileRegenerator groups by (BaseName, Lang) and routes new-file writes through GetNewLanguageFilePath(baseName, lang) - SyncState v3 schema (EntriesV3 outer-keyed by BaseName) with automatic v2 -> v3 migration on load; legacy 2-arg overloads preserved - PushCommand / PullCommand / CloneCommand switch to DiscoverResourceGroups and consume the new NewEntryHashesByGroup response shape Cloud API: - resource_keys, github_sync_state, pending_conflicts each get a base_name column with EF migrations; unique indexes extended to include it - KeySyncService, ResourceService, SyncHistoryService, GitHubSyncService, GitHubPullService, FileImportService all thread BaseName - File-path -> BaseName extraction for resx/json/xliff/po so GitHub pull maps repo files to their resource groups - New POST /api/projects/{id}/sync/migrate-groups endpoint for re-keying legacy BaseName="" rows when a single-group project grows a second group - New "lrm cloud migrate-groups --from --to" CLI command Cloud Web UI: - TranslationGrid grows a conditional Group column and toolbar filter when the loaded data spans multiple base names - AddKeyDialog grows a Resource Group picker (visible only when caller passes a multi-group list); Editor.razor builds the list from loaded rows - Web ResourceService and Api ResourcesController endpoints accept an optional baseName query parameter on get/update/delete VS Code extension: - resourceGroup field on ResourceKey / ResourceKeyDetails DTOs - Editor renders a Resource Group column when multi-group; edits forward resourceGroup so updates land in the right file - Dashboard math now correct because /api/stats is now group-aware Tests: - 6 new local discovery + controller integration tests - 5 new cloud server tests (KeySyncService, multi-group push, legacy empty-BaseName fallback, migrate-groups happy path + rollback) - Existing tests updated for v3 SyncState format and new error messages - Integration test fixture at cloud/full-integration-test/tests/05-multi-group.sh
1 parent cf7d46a commit ef9f6ee

82 files changed

Lines changed: 12531 additions & 711 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Commands/Cloud/CloneCommand.cs

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -496,15 +496,19 @@ private int PullResources(string targetDirectory, RemoteUrl remoteUrl, CloudConf
496496
return 0;
497497
}
498498

499-
// Get languages from the entries
499+
// Get (BaseName, Lang) pairs from the entries so multi-group projects
500+
// create one stub LanguageInfo per group + culture.
500501
// Note: After normalization, default language may be "" (for resx/android) or explicit code (for xliff/ios)
501-
var languages = mergeResult.ToWrite.Select(e => e.Lang).Distinct()
502-
.Select(lang => new Core.Models.LanguageInfo
502+
var languages = mergeResult.ToWrite
503+
.Select(e => (e.BaseName, e.Lang))
504+
.Distinct()
505+
.Select(pair => new Core.Models.LanguageInfo
503506
{
504-
Code = lang,
505-
Name = string.IsNullOrEmpty(lang) ? remoteProject.DefaultLanguage : lang,
506-
IsDefault = string.IsNullOrEmpty(lang) ||
507-
string.Equals(lang, remoteProject.DefaultLanguage, StringComparison.OrdinalIgnoreCase)
507+
Code = pair.Lang,
508+
Name = string.IsNullOrEmpty(pair.Lang) ? remoteProject.DefaultLanguage : pair.Lang,
509+
BaseName = pair.BaseName,
510+
IsDefault = string.IsNullOrEmpty(pair.Lang) ||
511+
string.Equals(pair.Lang, remoteProject.DefaultLanguage, StringComparison.OrdinalIgnoreCase)
508512
})
509513
.ToList();
510514

@@ -554,20 +558,12 @@ private int PullResources(string targetDirectory, RemoteUrl remoteUrl, CloudConf
554558
// Update sync state with entry-level hashes
555559
try
556560
{
557-
var syncState = new SyncState
558-
{
559-
Version = 2,
560-
Timestamp = DateTime.UtcNow,
561-
Entries = new Dictionary<string, Dictionary<string, string>>()
562-
};
561+
var syncState = SyncState.CreateNew();
562+
syncState.Timestamp = DateTime.UtcNow;
563563

564564
foreach (var entry in mergeResult.ToWrite)
565565
{
566-
if (!syncState.Entries.ContainsKey(entry.Key))
567-
{
568-
syncState.Entries[entry.Key] = new Dictionary<string, string>();
569-
}
570-
syncState.Entries[entry.Key][entry.Lang] = entry.Hash;
566+
syncState.SetEntryHash(entry.BaseName, entry.Key, entry.Lang, entry.Hash);
571567
}
572568

573569
SyncStateManager.SaveAsync(targetDirectory, syncState, ct).GetAwaiter().GetResult();
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using System.ComponentModel;
5+
using LocalizationManager.Core.Cloud;
6+
using LocalizationManager.Core.Cloud.Models;
7+
using Spectre.Console;
8+
using Spectre.Console.Cli;
9+
10+
namespace LocalizationManager.Commands.Cloud;
11+
12+
public class MigrateGroupsCommandSettings : BaseCommandSettings
13+
{
14+
[CommandOption("--from <BASE_NAME>")]
15+
[Description("BaseName to migrate FROM. Defaults to \"\" (legacy single-group rows).")]
16+
public string FromBaseName { get; set; } = string.Empty;
17+
18+
[CommandOption("--to <BASE_NAME>")]
19+
[Description("BaseName to migrate TO. Required; must be non-empty.")]
20+
public string? ToBaseName { get; set; }
21+
22+
[CommandOption("-y|--yes")]
23+
[Description("Skip the confirmation prompt.")]
24+
public bool Yes { get; set; }
25+
}
26+
27+
/// <summary>
28+
/// CLI command to bulk-rekey resource keys in the cloud project from one
29+
/// BaseName to another. Run this after upgrading a single-group project to a
30+
/// multi-group layout so existing cloud rows (originally stored with
31+
/// <c>BaseName=""</c>) match the new BaseName the client now sends.
32+
/// </summary>
33+
public class MigrateGroupsCommand : Command<MigrateGroupsCommandSettings>
34+
{
35+
public override int Execute(CommandContext context, MigrateGroupsCommandSettings settings, CancellationToken cancellationToken = default)
36+
{
37+
if (string.IsNullOrWhiteSpace(settings.ToBaseName))
38+
{
39+
AnsiConsole.MarkupLine("[red]--to is required and must be non-empty[/]");
40+
return 1;
41+
}
42+
43+
try
44+
{
45+
var projectDirectory = settings.GetResourcePath();
46+
var config = CloudConfigManager.LoadAsync(projectDirectory, cancellationToken).GetAwaiter().GetResult();
47+
var envApiKey = CloudConfigManager.GetApiKeyFromEnvironment();
48+
if (!string.IsNullOrWhiteSpace(envApiKey) && string.IsNullOrWhiteSpace(config.ApiKey))
49+
{
50+
config.ApiKey = envApiKey;
51+
}
52+
53+
if (!config.HasProject || !RemoteUrlParser.TryParse(config.Remote!, out var remoteUrl))
54+
{
55+
AnsiConsole.MarkupLine("[red]No cloud remote configured for this project. Run 'lrm cloud init' first.[/]");
56+
return 1;
57+
}
58+
59+
if (!config.IsLoggedIn)
60+
{
61+
AnsiConsole.MarkupLine("[red]Not authenticated. Run 'lrm cloud login' or 'lrm cloud set-api-key' first.[/]");
62+
return 1;
63+
}
64+
65+
using var apiClient = new CloudApiClient(remoteUrl!);
66+
if (!string.IsNullOrWhiteSpace(config.ApiKey))
67+
{
68+
apiClient.SetApiKey(config.ApiKey);
69+
}
70+
else if (!string.IsNullOrWhiteSpace(config.AccessToken))
71+
{
72+
apiClient.SetAccessToken(config.AccessToken);
73+
}
74+
75+
AnsiConsole.MarkupLine($"Migrating resource keys: BaseName [yellow]'{settings.FromBaseName.EscapeMarkup()}'[/] → [green]'{settings.ToBaseName!.EscapeMarkup()}'[/]");
76+
AnsiConsole.MarkupLine("[dim]This rekeys every row in the source group; conflicts (same KeyName already in target) are detected and rolled back.[/]");
77+
78+
if (!settings.Yes)
79+
{
80+
var confirm = AnsiConsole.Confirm("Proceed?", defaultValue: false);
81+
if (!confirm)
82+
{
83+
AnsiConsole.MarkupLine("[yellow]Aborted by user[/]");
84+
return 0;
85+
}
86+
}
87+
88+
var request = new MigrateGroupsRequest
89+
{
90+
FromBaseName = settings.FromBaseName,
91+
ToBaseName = settings.ToBaseName!
92+
};
93+
94+
MigrateGroupsResponse response;
95+
try
96+
{
97+
response = apiClient.MigrateGroupsAsync(request, cancellationToken).GetAwaiter().GetResult();
98+
}
99+
catch (CloudApiException ex) when (ex.StatusCode == 409)
100+
{
101+
AnsiConsole.MarkupLine("[red]Migration would conflict with existing rows in the target group.[/]");
102+
AnsiConsole.MarkupLine("[dim]Resolve the conflicts (delete one side, edit the target, etc.) and retry.[/]");
103+
return 1;
104+
}
105+
106+
if (response.ConflictingKeys.Count > 0)
107+
{
108+
AnsiConsole.MarkupLine($"[red]Migration aborted: {response.ConflictingKeys.Count} conflicting key(s):[/]");
109+
foreach (var key in response.ConflictingKeys.Take(10))
110+
{
111+
AnsiConsole.MarkupLine($" - {key.EscapeMarkup()}");
112+
}
113+
if (response.ConflictingKeys.Count > 10)
114+
{
115+
AnsiConsole.MarkupLine($" ... and {response.ConflictingKeys.Count - 10} more");
116+
}
117+
return 1;
118+
}
119+
120+
AnsiConsole.MarkupLine($"[green]✓ Migrated {response.RowsUpdated} resource key(s)[/]");
121+
return 0;
122+
}
123+
catch (Exception ex)
124+
{
125+
AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]");
126+
return 1;
127+
}
128+
}
129+
}

Commands/Cloud/PullCommand.cs

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ public override int Execute(CommandContext context, PullCommandSettings settings
207207
}
208208

209209
// Extract local entries
210-
var languages = backend.Discovery.DiscoverLanguages(projectDirectory);
210+
var directory = backend.Discovery.DiscoverResourceGroups(projectDirectory);
211+
var languages = directory.Groups.SelectMany(g => g.Files).ToList();
211212
var extractor = new LocalEntryExtractor(backend);
212213

213214
List<LocalEntry> localEntries = new();
@@ -272,12 +273,13 @@ public override int Execute(CommandContext context, PullCommandSettings settings
272273
var resolutions = mergeResult.Conflicts.Select(c => new ConflictResolution
273274
{
274275
Key = c.Key,
276+
BaseName = c.BaseName,
275277
Lang = c.Lang,
276278
TargetType = ResolutionTargetType.Entry,
277279
Resolution = ResolutionChoice.Remote
278280
}).ToList();
279281

280-
var localEntriesDict = localEntries.ToDictionary(e => (e.Key, e.Lang), e => e);
282+
var localEntriesDict = localEntries.ToDictionary(e => (e.BaseName, e.Key, e.Lang), e => e);
281283
mergeResult = merger.ApplyResolutions(mergeResult, resolutions, localEntriesDict);
282284
}
283285
else if (strategy == ResolutionChoice.Local)
@@ -288,12 +290,13 @@ public override int Execute(CommandContext context, PullCommandSettings settings
288290
var resolutions = mergeResult.Conflicts.Select(c => new ConflictResolution
289291
{
290292
Key = c.Key,
293+
BaseName = c.BaseName,
291294
Lang = c.Lang,
292295
TargetType = ResolutionTargetType.Entry,
293296
Resolution = ResolutionChoice.Local
294297
}).ToList();
295298

296-
var localEntriesDict = localEntries.ToDictionary(e => (e.Key, e.Lang), e => e);
299+
var localEntriesDict = localEntries.ToDictionary(e => (e.BaseName, e.Key, e.Lang), e => e);
297300
mergeResult = merger.ApplyResolutions(mergeResult, resolutions, localEntriesDict);
298301
}
299302
else if (strategy == ResolutionChoice.Skip)
@@ -575,7 +578,7 @@ private bool ResolveConflictsInteractively(KeyLevelMerger merger, MergeResult me
575578
}
576579

577580
// Apply resolutions
578-
var localEntriesDict = localEntries.ToDictionary(e => (e.Key, e.Lang), e => e);
581+
var localEntriesDict = localEntries.ToDictionary(e => (e.BaseName, e.Key, e.Lang), e => e);
579582
var resolvedResult = merger.ApplyResolutions(mergeResult, resolutions, localEntriesDict);
580583

581584
// Copy resolved entries to original result
@@ -600,43 +603,27 @@ private bool ResolveConflictsInteractively(KeyLevelMerger merger, MergeResult me
600603

601604
private SyncState BuildNewSyncState(MergeResult mergeResult, List<LocalEntry> localEntries)
602605
{
603-
var newState = new SyncState
604-
{
605-
Version = 2,
606-
Timestamp = DateTime.UtcNow,
607-
Entries = new Dictionary<string, Dictionary<string, string>>()
608-
};
606+
var newState = SyncState.CreateNew();
607+
newState.Timestamp = DateTime.UtcNow;
609608

610-
// Add hashes from merged entries
611-
foreach (var (key, lang, hash) in mergeResult.NewHashes.GetAllEntries())
609+
// Hashes from merged entries (multi-group-aware).
610+
foreach (var (baseName, key, lang, hash) in mergeResult.NewHashes.GetAllEntries())
612611
{
613-
if (!newState.Entries.ContainsKey(key))
614-
{
615-
newState.Entries[key] = new Dictionary<string, string>();
616-
}
617-
newState.Entries[key][lang] = hash;
612+
newState.SetEntryHash(baseName, key, lang, hash);
618613
}
619614

620-
// Add hashes from written entries (remote entries accepted)
615+
// Hashes from written entries (remote entries accepted).
621616
foreach (var entry in mergeResult.ToWrite)
622617
{
623-
if (!newState.Entries.ContainsKey(entry.Key))
624-
{
625-
newState.Entries[entry.Key] = new Dictionary<string, string>();
626-
}
627-
newState.Entries[entry.Key][entry.Lang] = entry.Hash;
618+
newState.SetEntryHash(entry.BaseName, entry.Key, entry.Lang, entry.Hash);
628619
}
629620

630-
// Add hashes from local entries that weren't changed
621+
// Hashes from local entries that weren't changed (don't overwrite).
631622
foreach (var entry in localEntries)
632623
{
633-
if (!newState.Entries.ContainsKey(entry.Key))
634-
{
635-
newState.Entries[entry.Key] = new Dictionary<string, string>();
636-
}
637-
if (!newState.Entries[entry.Key].ContainsKey(entry.Lang))
624+
if (newState.GetEntryHash(entry.BaseName, entry.Key, entry.Lang) == null)
638625
{
639-
newState.Entries[entry.Key][entry.Lang] = entry.Hash;
626+
newState.SetEntryHash(entry.BaseName, entry.Key, entry.Lang, entry.Hash);
640627
}
641628
}
642629

Commands/Cloud/PushCommand.cs

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@ public override int Execute(CommandContext context, PushCommandSettings settings
209209

210210
// Extract local entries
211211
var extractor = new LocalEntryExtractor(backend);
212-
var languages = backend.Discovery.DiscoverLanguages(projectDirectory);
212+
var directory = backend.Discovery.DiscoverResourceGroups(projectDirectory);
213+
var languages = directory.Groups.SelectMany(g => g.Files).ToList();
213214

214215
List<LocalEntry> localEntries = new();
215216
AnsiConsole.Status()
@@ -451,37 +452,52 @@ private SyncState UpdateSyncState(
451452
KeySyncPushResponse response,
452453
List<LocalEntry> localEntries)
453454
{
454-
var newState = new SyncState
455+
var newState = SyncState.CreateNew();
456+
newState.Timestamp = DateTime.UtcNow;
457+
458+
// Carry forward existing hashes (preserves entries not touched by this push).
459+
if (existing != null)
455460
{
456-
Version = 2,
457-
Timestamp = DateTime.UtcNow,
458-
Entries = existing?.Entries ?? new Dictionary<string, Dictionary<string, string>>()
459-
};
461+
foreach (var (baseName, key, lang, hash) in existing.EnumerateEntries())
462+
{
463+
newState.SetEntryHash(baseName, key, lang, hash);
464+
}
465+
}
460466

461-
// Update entry hashes from response
462-
foreach (var (key, langHashes) in response.NewEntryHashes)
467+
// Update entry hashes from response. Prefer the multi-group-aware map
468+
// when present; fall back to the legacy flat map (which stored
469+
// BaseName="") for older servers.
470+
if (response.NewEntryHashesByGroup is { Count: > 0 })
463471
{
464-
if (!newState.Entries.ContainsKey(key))
472+
foreach (var (baseName, byKey) in response.NewEntryHashesByGroup)
465473
{
466-
newState.Entries[key] = new Dictionary<string, string>();
474+
foreach (var (key, byLang) in byKey)
475+
{
476+
foreach (var (lang, hash) in byLang)
477+
{
478+
newState.SetEntryHash(baseName, key, lang, hash);
479+
}
480+
}
467481
}
468-
foreach (var (lang, hash) in langHashes)
482+
}
483+
else
484+
{
485+
foreach (var (key, byLang) in response.NewEntryHashes)
469486
{
470-
newState.Entries[key][lang] = hash;
487+
foreach (var (lang, hash) in byLang)
488+
{
489+
newState.SetEntryHash(string.Empty, key, lang, hash);
490+
}
471491
}
472492
}
473493

474-
// For entries that were pushed but not modified on server (hash unchanged),
475-
// update with local hashes
494+
// For entries that were pushed but unchanged on server (hash matches),
495+
// ensure their hash is recorded.
476496
foreach (var entry in localEntries)
477497
{
478-
if (!newState.Entries.ContainsKey(entry.Key))
479-
{
480-
newState.Entries[entry.Key] = new Dictionary<string, string>();
481-
}
482-
if (!newState.Entries[entry.Key].ContainsKey(entry.Lang))
498+
if (newState.GetEntryHash(entry.BaseName, entry.Key, entry.Lang) == null)
483499
{
484-
newState.Entries[entry.Key][entry.Lang] = entry.Hash;
500+
newState.SetEntryHash(entry.BaseName, entry.Key, entry.Lang, entry.Hash);
485501
}
486502
}
487503

0 commit comments

Comments
 (0)