Skip to content

Commit cd7c8d3

Browse files
committed
Add PO and XLIFF format support to cloud project creation
- Add PO and XLIFF to ProjectFormat constants - Add format-specific configuration options to CreateProjectRequest - Update CreateProjectDialog with dynamic format-specific settings UI - Update ProjectService to generate default config for PO/XLIFF formats - Update GitHubSyncService with file patterns for PO/XLIFF discovery
1 parent 8033e1e commit cd7c8d3

5 files changed

Lines changed: 213 additions & 15 deletions

File tree

cloud/src/LrmCloud.Api/Services/GitHubSyncService.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,18 @@ This PR updates translations from [LRM Cloud](https://lrmcloud.com).
500500
? dirName[..^6]
501501
: null,
502502

503+
// PO: fr.po → fr, messages.pot → default
504+
"po" or "gettext" => fileName.EndsWith(".pot", StringComparison.OrdinalIgnoreCase)
505+
? "default"
506+
: Path.GetFileNameWithoutExtension(fileName),
507+
508+
// XLIFF: messages.fr.xliff → fr, messages.xliff → default
509+
"xliff" or "xlf" => fileName.Contains('.')
510+
? fileName.Split('.').Length > 2
511+
? fileName.Split('.')[^2]
512+
: "default"
513+
: null,
514+
503515
_ => null
504516
};
505517
}

cloud/src/LrmCloud.Api/Services/ProjectService.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ public ProjectService(
120120
SyncStatus = SyncStatus.Pending,
121121
CreatedAt = DateTime.UtcNow,
122122
UpdatedAt = DateTime.UtcNow,
123-
// Auto-generate default config based on format
124-
ConfigJson = GenerateDefaultConfig(format, request.DefaultLanguage),
123+
// Auto-generate default config based on format and options
124+
ConfigJson = GenerateDefaultConfig(format, request.DefaultLanguage, request.FormatOptions),
125125
ConfigVersion = Guid.NewGuid().ToString(),
126126
ConfigUpdatedAt = DateTime.UtcNow,
127127
ConfigUpdatedBy = userId
@@ -915,7 +915,7 @@ private ProjectDto MapToProjectDto(Project project, int userId)
915915
/// <summary>
916916
/// Generates a default lrm.json configuration based on project format.
917917
/// </summary>
918-
internal static string GenerateDefaultConfig(string format, string defaultLanguage)
918+
internal static string GenerateDefaultConfig(string format, string defaultLanguage, FormatOptionsDto? options = null)
919919
{
920920
var config = new Dictionary<string, object?>
921921
{
@@ -928,31 +928,47 @@ internal static string GenerateDefaultConfig(string format, string defaultLangua
928928
{
929929
config["Json"] = new Dictionary<string, object>
930930
{
931-
["I18nextCompatible"] = format == "i18next"
931+
["I18nextCompatible"] = format == "i18next",
932+
["UseNestedKeys"] = options?.JsonNestedKeys ?? false
932933
};
933934
}
934935
else if (format == "resx")
935936
{
936-
// RESX: Use SharedResource as default per ASP.NET Core convention
937937
config["Resx"] = new Dictionary<string, object>
938938
{
939-
["BaseName"] = "SharedResource"
939+
["BaseName"] = options?.BaseName ?? "SharedResource"
940940
};
941941
}
942942
else if (format == "android")
943943
{
944-
// Android: Use strings as default base name
945944
config["Android"] = new Dictionary<string, object>
946945
{
947-
["BaseName"] = "strings"
946+
["BaseName"] = options?.BaseName ?? "strings"
948947
};
949948
}
950949
else if (format == "ios")
951950
{
952-
// iOS: Use Localizable as default base name
953951
config["Ios"] = new Dictionary<string, object>
954952
{
955-
["BaseName"] = "Localizable"
953+
["BaseName"] = options?.BaseName ?? "Localizable"
954+
};
955+
}
956+
else if (format == "po")
957+
{
958+
config["Po"] = new Dictionary<string, object>
959+
{
960+
["Domain"] = options?.PoDomain ?? "messages",
961+
["FolderStructure"] = options?.PoFolderStructure ?? "gnu",
962+
["KeyStrategy"] = options?.PoKeyStrategy ?? "auto"
963+
};
964+
}
965+
else if (format == "xliff")
966+
{
967+
config["Xliff"] = new Dictionary<string, object>
968+
{
969+
["Version"] = options?.XliffVersion ?? "2.0",
970+
["Bilingual"] = options?.XliffBilingual ?? false,
971+
["FileExtension"] = ".xliff"
956972
};
957973
}
958974

cloud/src/LrmCloud.Shared/Constants/ProjectFormat.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@ public static class ProjectFormat
3030
/// </summary>
3131
public const string Ios = "ios";
3232

33+
/// <summary>
34+
/// GNU gettext PO format
35+
/// </summary>
36+
public const string Po = "po";
37+
38+
/// <summary>
39+
/// OASIS XLIFF format
40+
/// </summary>
41+
public const string Xliff = "xliff";
42+
3343
/// <summary>
3444
/// All valid formats
3545
/// </summary>
36-
public static readonly string[] All = { Resx, Json, I18Next, Android, Ios };
46+
public static readonly string[] All = { Resx, Json, I18Next, Android, Ios, Po, Xliff };
3747

3848
/// <summary>
3949
/// Check if a format is valid
@@ -54,6 +64,8 @@ public static string[] GetExtensions(string format)
5464
Json or I18Next => new[] { ".json" },
5565
Android => new[] { ".xml" },
5666
Ios => new[] { ".strings", ".stringsdict" },
67+
Po => new[] { ".po", ".pot" },
68+
Xliff => new[] { ".xliff", ".xlf" },
5769
_ => Array.Empty<string>()
5870
};
5971
}

cloud/src/LrmCloud.Shared/DTOs/Projects/CreateProjectRequest.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,28 @@ public class CreateProjectRequest
4949

5050
[MaxLength(100, ErrorMessage = "GitHub default branch must not exceed 100 characters")]
5151
public string? GitHubDefaultBranch { get; set; }
52+
53+
// Format-specific options (optional)
54+
public FormatOptionsDto? FormatOptions { get; set; }
55+
}
56+
57+
/// <summary>
58+
/// Format-specific configuration options for project creation.
59+
/// </summary>
60+
public class FormatOptionsDto
61+
{
62+
// PO options
63+
public string? PoDomain { get; set; }
64+
public string? PoFolderStructure { get; set; } // "gnu" or "flat"
65+
public string? PoKeyStrategy { get; set; } // "auto", "msgid", "context"
66+
67+
// XLIFF options
68+
public string? XliffVersion { get; set; } // "1.2" or "2.0"
69+
public bool? XliffBilingual { get; set; }
70+
71+
// JSON options
72+
public bool? JsonNestedKeys { get; set; }
73+
74+
// Common options (base filename)
75+
public string? BaseName { get; set; }
5276
}

cloud/src/LrmCloud.Web/Components/CreateProjectDialog.razor

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@
3535
<RadzenFormField Text="Format" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
3636
<ChildContent>
3737
<RadzenDropDown @bind-Value="_model.Format" Style="width: 100%;"
38-
Data="@_formatOptions" TextProperty="Text" ValueProperty="Value"
39-
Disabled="@_isSubmitting" />
38+
Data="@_formatDropdownOptions" TextProperty="Text" ValueProperty="Value"
39+
Disabled="@_isSubmitting" Change="@OnFormatChanged" />
4040
</ChildContent>
4141
</RadzenFormField>
4242

@@ -60,6 +60,85 @@
6060
</Helper>
6161
</RadzenFormField>
6262

63+
@* Format-specific options *@
64+
@if (HasFormatOptions)
65+
{
66+
<details open="@_formatOptionsOpen">
67+
<summary style="cursor: pointer; font-weight: 500; margin-bottom: 0.5rem;" @onclick="() => _formatOptionsOpen = !_formatOptionsOpen">Format Options</summary>
68+
<RadzenStack Gap="0.75rem" Style="margin-top: 0.5rem;">
69+
@switch (_model.Format)
70+
{
71+
case "po":
72+
<RadzenFormField Text="Domain" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
73+
<ChildContent>
74+
<RadzenTextBox @bind-Value="_formatOptions.PoDomain" Placeholder="messages"
75+
Disabled="@_isSubmitting" Style="width: 100%;" />
76+
</ChildContent>
77+
<Helper>
78+
<RadzenText TextStyle="TextStyle.Caption" class="rz-color-secondary">PO file domain name (e.g., messages, django)</RadzenText>
79+
</Helper>
80+
</RadzenFormField>
81+
<RadzenFormField Text="Folder Structure" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
82+
<ChildContent>
83+
<RadzenDropDown @bind-Value="_formatOptions.PoFolderStructure" Style="width: 100%;"
84+
Data="@_poFolderOptions" TextProperty="Text" ValueProperty="Value"
85+
Disabled="@_isSubmitting" />
86+
</ChildContent>
87+
<Helper>
88+
<RadzenText TextStyle="TextStyle.Caption" class="rz-color-secondary">GNU: locale/{'{'}lang{'}'}/LC_MESSAGES/, Flat: po/{'{'}lang{'}'}.po</RadzenText>
89+
</Helper>
90+
</RadzenFormField>
91+
<RadzenFormField Text="Key Strategy" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
92+
<ChildContent>
93+
<RadzenDropDown @bind-Value="_formatOptions.PoKeyStrategy" Style="width: 100%;"
94+
Data="@_poKeyStrategyOptions" TextProperty="Text" ValueProperty="Value"
95+
Disabled="@_isSubmitting" />
96+
</ChildContent>
97+
<Helper>
98+
<RadzenText TextStyle="TextStyle.Caption" class="rz-color-secondary">How to derive keys from PO entries</RadzenText>
99+
</Helper>
100+
</RadzenFormField>
101+
break;
102+
103+
case "xliff":
104+
<RadzenFormField Text="XLIFF Version" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
105+
<ChildContent>
106+
<RadzenDropDown @bind-Value="_formatOptions.XliffVersion" Style="width: 100%;"
107+
Data="@_xliffVersionOptions" TextProperty="Text" ValueProperty="Value"
108+
Disabled="@_isSubmitting" />
109+
</ChildContent>
110+
</RadzenFormField>
111+
<RadzenStack Orientation="Radzen.Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
112+
<RadzenCheckBox @bind-Value="_formatOptions.XliffBilingual" Disabled="@_isSubmitting" />
113+
<RadzenText>Bilingual files (include source text in all files)</RadzenText>
114+
</RadzenStack>
115+
break;
116+
117+
case "json":
118+
<RadzenStack Orientation="Radzen.Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
119+
<RadzenCheckBox @bind-Value="_formatOptions.JsonNestedKeys" Disabled="@_isSubmitting" />
120+
<RadzenText>Use nested keys (e.g., common.buttons.save)</RadzenText>
121+
</RadzenStack>
122+
break;
123+
124+
case "resx":
125+
case "android":
126+
case "ios":
127+
<RadzenFormField Text="Base Filename" Variant="Radzen.Variant.Outlined" Style="width: 100%;">
128+
<ChildContent>
129+
<RadzenTextBox @bind-Value="_formatOptions.BaseName" Placeholder="@GetDefaultBaseName()"
130+
Disabled="@_isSubmitting" Style="width: 100%;" />
131+
</ChildContent>
132+
<Helper>
133+
<RadzenText TextStyle="TextStyle.Caption" class="rz-color-secondary">@GetBaseNameHelp()</RadzenText>
134+
</Helper>
135+
</RadzenFormField>
136+
break;
137+
}
138+
</RadzenStack>
139+
</details>
140+
}
141+
63142
<details>
64143
<summary style="cursor: pointer; font-weight: 500; margin-bottom: 0.5rem;">GitHub Integration (Optional)</summary>
65144
<RadzenStack Gap="1rem" Style="margin-top: 0.5rem;">
@@ -106,16 +185,69 @@
106185
public EventCallback<ProjectDto> OnProjectCreated { get; set; }
107186

108187
private bool _isSubmitting;
188+
private bool _formatOptionsOpen = true;
109189
private string? _errorMessage;
110190
private CreateProjectRequest _model = new() { Slug = "", Name = "", Format = "resx", DefaultLanguage = "en", LocalizationPath = "." };
191+
private FormatOptionsDto _formatOptions = new();
111192

112-
private readonly List<object> _formatOptions = new()
193+
private readonly List<object> _formatDropdownOptions = new()
113194
{
114195
new { Value = "resx", Text = ".resx (C#/.NET)" },
115196
new { Value = "json", Text = "JSON Localization" },
116197
new { Value = "i18next", Text = "i18next (JS/React)" },
117198
new { Value = "android", Text = "Android strings.xml" },
118-
new { Value = "ios", Text = "iOS .strings/.stringsdict" }
199+
new { Value = "ios", Text = "iOS .strings/.stringsdict" },
200+
new { Value = "po", Text = "GNU gettext (.po/.pot)" },
201+
new { Value = "xliff", Text = "XLIFF (.xliff/.xlf)" }
202+
};
203+
204+
private readonly List<object> _poFolderOptions = new()
205+
{
206+
new { Value = "gnu", Text = "GNU (locale/{lang}/LC_MESSAGES/)" },
207+
new { Value = "flat", Text = "Flat (po/{lang}.po)" }
208+
};
209+
210+
private readonly List<object> _poKeyStrategyOptions = new()
211+
{
212+
new { Value = "auto", Text = "Auto (context if available, else msgid)" },
213+
new { Value = "msgid", Text = "Use msgid (source text as key)" },
214+
new { Value = "context", Text = "Use msgctxt (context as key)" }
215+
};
216+
217+
private readonly List<object> _xliffVersionOptions = new()
218+
{
219+
new { Value = "2.0", Text = "XLIFF 2.0 (Recommended)" },
220+
new { Value = "1.2", Text = "XLIFF 1.2 (Legacy)" }
221+
};
222+
223+
private bool HasFormatOptions => _model.Format is "po" or "xliff" or "json" or "resx" or "android" or "ios";
224+
225+
private void OnFormatChanged(object value)
226+
{
227+
// Reset format options when format changes
228+
_formatOptions = new FormatOptionsDto
229+
{
230+
PoFolderStructure = "gnu",
231+
PoKeyStrategy = "auto",
232+
XliffVersion = "2.0"
233+
};
234+
StateHasChanged();
235+
}
236+
237+
private string GetDefaultBaseName() => _model.Format switch
238+
{
239+
"resx" => "SharedResource",
240+
"android" => "strings",
241+
"ios" => "Localizable",
242+
_ => ""
243+
};
244+
245+
private string GetBaseNameHelp() => _model.Format switch
246+
{
247+
"resx" => "e.g., SharedResource, Resources, Strings",
248+
"android" => "e.g., strings, messages",
249+
"ios" => "e.g., Localizable, InfoPlist",
250+
_ => ""
119251
};
120252

121253
private void Close()
@@ -130,6 +262,8 @@
130262

131263
try
132264
{
265+
// Attach format options to the model
266+
_model.FormatOptions = _formatOptions;
133267
var result = await ProjectService.CreateProjectAsync(_model);
134268

135269
if (result.IsSuccess && result.Data != null)

0 commit comments

Comments
 (0)