mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
Add automatic screenshot caption translation system.
- Introduced a new migration to create the SoftwareScreenshotCaptionTranslations table for storing translated captions of software screenshots. - Updated MarechaiContext to include a DbSet for SoftwareScreenshotCaptionTranslation. - Implemented the SoftwareScreenshotCaptionTranslation model with necessary properties and relationships. - Enhanced SoftwareScreenshotsController to support fetching localized captions based on the requested language. - Added SoftwareScreenshotCaptionTranslationProvider to handle background translation tasks for missing captions. - Updated SoftwareService to pass the UI language when fetching screenshots.
This commit is contained in:
@@ -14,6 +14,14 @@ namespace Marechai.ApiClient.Models
|
||||
{
|
||||
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
|
||||
public IDictionary<string, object> AdditionalData { get; set; }
|
||||
/// <summary>The canonical_caption property</summary>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public string? CanonicalCaption { get; set; }
|
||||
#nullable restore
|
||||
#else
|
||||
public string CanonicalCaption { get; set; }
|
||||
#endif
|
||||
/// <summary>The caption property</summary>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
@@ -87,6 +95,7 @@ namespace Marechai.ApiClient.Models
|
||||
{
|
||||
return new Dictionary<string, Action<IParseNode>>
|
||||
{
|
||||
{ "canonical_caption", n => { CanonicalCaption = n.GetStringValue(); } },
|
||||
{ "caption", n => { Caption = n.GetStringValue(); } },
|
||||
{ "id", n => { Id = n.GetGuidValue(); } },
|
||||
{ "original_extension", n => { OriginalExtension = n.GetStringValue(); } },
|
||||
@@ -105,6 +114,7 @@ namespace Marechai.ApiClient.Models
|
||||
public virtual void Serialize(ISerializationWriter writer)
|
||||
{
|
||||
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
|
||||
writer.WriteStringValue("canonical_caption", CanonicalCaption);
|
||||
writer.WriteStringValue("caption", Caption);
|
||||
writer.WriteGuidValue("id", Id);
|
||||
writer.WriteStringValue("original_extension", OriginalExtension);
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
|
||||
/// </summary>
|
||||
/// <param name="pathParameters">Path parameters for the request</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public ScreenshotsItemRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}", pathParameters)
|
||||
public ScreenshotsItemRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}{?lang*}", pathParameters)
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
@@ -30,7 +30,7 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
|
||||
/// </summary>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public ScreenshotsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}", rawUrl)
|
||||
public ScreenshotsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots/{id}{?lang*}", rawUrl)
|
||||
{
|
||||
}
|
||||
/// <returns>A <see cref="Stream"/></returns>
|
||||
@@ -61,11 +61,11 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
|
||||
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
public async Task<global::Marechai.ApiClient.Models.SoftwareScreenshotDto> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#endif
|
||||
var requestInfo = ToGetRequestInformation(requestConfiguration);
|
||||
@@ -121,11 +121,11 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>>? requestConfiguration = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>> requestConfiguration = default)
|
||||
{
|
||||
#endif
|
||||
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
|
||||
@@ -168,13 +168,28 @@ namespace Marechai.ApiClient.Software.Screenshots.Item
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class ScreenshotsItemRequestBuilderDeleteRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
|
||||
{
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
#pragma warning disable CS1591
|
||||
public partial class ScreenshotsItemRequestBuilderGetQueryParameters
|
||||
#pragma warning restore CS1591
|
||||
{
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
[QueryParameter("lang")]
|
||||
public string? Lang { get; set; }
|
||||
#nullable restore
|
||||
#else
|
||||
[QueryParameter("lang")]
|
||||
public string Lang { get; set; }
|
||||
#endif
|
||||
}
|
||||
/// <summary>
|
||||
/// Configuration for the request such as headers, query parameters, and middleware options.
|
||||
/// </summary>
|
||||
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class ScreenshotsItemRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
|
||||
public partial class ScreenshotsItemRequestBuilderGetRequestConfiguration : RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.Item.ScreenshotsItemRequestBuilder.ScreenshotsItemRequestBuilderGetQueryParameters>
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
|
||||
@@ -60,7 +60,7 @@ namespace Marechai.ApiClient.Software.Screenshots
|
||||
/// </summary>
|
||||
/// <param name="pathParameters">Path parameters for the request</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public ScreenshotsRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots", pathParameters)
|
||||
public ScreenshotsRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots{?lang*}", pathParameters)
|
||||
{
|
||||
}
|
||||
/// <summary>
|
||||
@@ -68,7 +68,7 @@ namespace Marechai.ApiClient.Software.Screenshots
|
||||
/// </summary>
|
||||
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
|
||||
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
|
||||
public ScreenshotsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots", rawUrl)
|
||||
public ScreenshotsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/screenshots{?lang*}", rawUrl)
|
||||
{
|
||||
}
|
||||
/// <returns>A List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto></returns>
|
||||
@@ -76,11 +76,11 @@ namespace Marechai.ApiClient.Software.Screenshots
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>?> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
public async Task<List<global::Marechai.ApiClient.Models.SoftwareScreenshotDto>> GetAsync(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#endif
|
||||
var requestInfo = ToGetRequestInformation(requestConfiguration);
|
||||
@@ -91,11 +91,11 @@ namespace Marechai.ApiClient.Software.Screenshots
|
||||
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>>? requestConfiguration = default)
|
||||
{
|
||||
#nullable restore
|
||||
#else
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
|
||||
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>> requestConfiguration = default)
|
||||
{
|
||||
#endif
|
||||
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
|
||||
@@ -112,12 +112,27 @@ namespace Marechai.ApiClient.Software.Screenshots
|
||||
{
|
||||
return new global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder(rawUrl, RequestAdapter);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
#pragma warning disable CS1591
|
||||
public partial class ScreenshotsRequestBuilderGetQueryParameters
|
||||
#pragma warning restore CS1591
|
||||
{
|
||||
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
|
||||
#nullable enable
|
||||
[QueryParameter("lang")]
|
||||
public string? Lang { get; set; }
|
||||
#nullable restore
|
||||
#else
|
||||
[QueryParameter("lang")]
|
||||
public string Lang { get; set; }
|
||||
#endif
|
||||
}
|
||||
/// <summary>
|
||||
/// Configuration for the request such as headers, query parameters, and middleware options.
|
||||
/// </summary>
|
||||
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
|
||||
public partial class ScreenshotsRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
|
||||
public partial class ScreenshotsRequestBuilderGetRequestConfiguration : RequestConfiguration<global::Marechai.ApiClient.Software.Screenshots.ScreenshotsRequestBuilder.ScreenshotsRequestBuilderGetQueryParameters>
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"descriptionHash": "C8BE3C4A928AAB7DF91A2FFA3C0C8316578EB8277ECA10289413BF28D7321E3211BA7260DCEB540788A6157F97962C68315DEB0077333BA19B4E4AC9A0B82736",
|
||||
"descriptionHash": "F3336F2FFC38B52D0D4357D4620CF3C84D134027812D2A4D98AB2B36EC56350296959FED844511F3AF5078328BF198486A41AA161BA5184EC96D16099EF3B3FE",
|
||||
"descriptionLocation": "../../../../../tmp/openapi.json",
|
||||
"lockFileVersion": "1.0.0",
|
||||
"kiotaVersion": "1.31.1",
|
||||
|
||||
@@ -502,7 +502,12 @@ public class SoftwareBrowsingService
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _apiClient.Software.Screenshots[screenshotId.ToString()].GetAsync();
|
||||
string lang = GetIso639CodeFromCulture();
|
||||
|
||||
return await _apiClient.Software.Screenshots[screenshotId.ToString()].GetAsync(config =>
|
||||
{
|
||||
config.QueryParameters.Lang = lang;
|
||||
});
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
|
||||
@@ -50,9 +50,23 @@ public class SoftwareScreenshotDto : BaseDto<Guid>
|
||||
[JsonPropertyName("version_string")]
|
||||
public string? VersionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Caption shown to the end user. Localized to the requested language when the read
|
||||
/// endpoint receives <c>?lang=</c> (or via the <c>Accept-Language</c> header) and a
|
||||
/// translation row exists in <c>SoftwareScreenshotCaptionTranslations</c>; falls back to
|
||||
/// the canonical English caption (= <see cref="CanonicalCaption" />) otherwise.
|
||||
/// </summary>
|
||||
[JsonPropertyName("caption")]
|
||||
public string? Caption { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The canonical English caption from <c>SoftwareScreenshots.Caption</c>. Always populated
|
||||
/// identically to the underlying column regardless of the requested language so admin
|
||||
/// edit-path UIs can rewrite the source-of-truth value rather than a localized copy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canonical_caption")]
|
||||
public string? CanonicalCaption { get; set; }
|
||||
|
||||
[JsonPropertyName("original_extension")]
|
||||
[Required]
|
||||
public string OriginalExtension { get; set; }
|
||||
|
||||
11505
Marechai.Database/Migrations/20260514221254_AddSoftwareScreenshotCaptionTranslations.Designer.cs
generated
Normal file
11505
Marechai.Database/Migrations/20260514221254_AddSoftwareScreenshotCaptionTranslations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marechai.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSoftwareScreenshotCaptionTranslations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SoftwareScreenshotCaptionTranslations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
ScreenshotId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
|
||||
LanguageCode = table.Column<string>(type: "char(3)", maxLength: 3, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Caption = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
CreatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
UpdatedOn = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.ComputedColumn)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SoftwareScreenshotCaptionTranslations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "fk_software_screenshot_caption_translations_language",
|
||||
column: x => x.LanguageCode,
|
||||
principalTable: "ISO_639-3",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_software_screenshot_caption_translations_screenshot",
|
||||
column: x => x.ScreenshotId,
|
||||
principalTable: "SoftwareScreenshots",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_software_screenshot_caption_translations_screenshot_language",
|
||||
table: "SoftwareScreenshotCaptionTranslations",
|
||||
columns: new[] { "ScreenshotId", "LanguageCode" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SoftwareScreenshotCaptionTranslations_LanguageCode",
|
||||
table: "SoftwareScreenshotCaptionTranslations",
|
||||
column: "LanguageCode");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SoftwareScreenshotCaptionTranslations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7605,6 +7605,49 @@ namespace Marechai.Database.Migrations
|
||||
b.ToTable("SoftwareScreenshots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareScreenshotCaptionTranslation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("CreatedOn")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
|
||||
|
||||
b.Property<string>("LanguageCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("char(3)");
|
||||
|
||||
b.Property<Guid>("ScreenshotId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedOn")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property<DateTime>("UpdatedOn"));
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LanguageCode");
|
||||
|
||||
b.HasIndex("ScreenshotId", "LanguageCode")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("idx_software_screenshot_caption_translations_screenshot_language");
|
||||
|
||||
b.ToTable("SoftwareScreenshotCaptionTranslations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareUserRating", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
@@ -10637,6 +10680,27 @@ namespace Marechai.Database.Migrations
|
||||
b.Navigation("Version");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareScreenshotCaptionTranslation", b =>
|
||||
{
|
||||
b.HasOne("Marechai.Database.Models.Iso639", "Language")
|
||||
.WithMany()
|
||||
.HasForeignKey("LanguageCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_software_screenshot_caption_translations_language");
|
||||
|
||||
b.HasOne("Marechai.Database.Models.SoftwareScreenshot", "Screenshot")
|
||||
.WithMany()
|
||||
.HasForeignKey("ScreenshotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_software_screenshot_caption_translations_screenshot");
|
||||
|
||||
b.Navigation("Language");
|
||||
|
||||
b.Navigation("Screenshot");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marechai.Database.Models.SoftwareUserRating", b =>
|
||||
{
|
||||
b.HasOne("Marechai.Database.Models.Software", "Software")
|
||||
|
||||
@@ -156,6 +156,7 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
|
||||
public virtual DbSet<UnM49BySoftwareRelease> UnM49BySoftwareRelease { get; set; }
|
||||
public virtual DbSet<LanguageBySoftwareRelease> LanguageBySoftwareRelease { get; set; }
|
||||
public virtual DbSet<SoftwareScreenshot> SoftwareScreenshots { get; set; }
|
||||
public virtual DbSet<SoftwareScreenshotCaptionTranslation> SoftwareScreenshotCaptionTranslations { get; set; }
|
||||
public virtual DbSet<SoftwareCover> SoftwareCovers { get; set; }
|
||||
public virtual DbSet<SoftwareCoverCaptionTranslation> SoftwareCoverCaptionTranslations { get; set; }
|
||||
public virtual DbSet<PeopleBySoftwareRoleTranslation> PeopleBySoftwareRoleTranslations { get; set; }
|
||||
@@ -2949,6 +2950,24 @@ public class MarechaiContext : IdentityDbContext<ApplicationUser, ApplicationRol
|
||||
.HasConstraintName("fk_people_by_software_role_translations_language");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SoftwareScreenshotCaptionTranslation>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => new { e.ScreenshotId, e.LanguageCode })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("idx_software_screenshot_caption_translations_screenshot_language");
|
||||
|
||||
entity.HasOne(e => e.Screenshot)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ScreenshotId)
|
||||
.HasConstraintName("fk_software_screenshot_caption_translations_screenshot")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Language)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.LanguageCode)
|
||||
.HasConstraintName("fk_software_screenshot_caption_translations_language");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MobyGamesPromoArtDownloadState>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.PromoPageUrl).IsUnique();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ License ] --------------------------------------------------------------
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright © 2003-2026 Natalia Portillo
|
||||
*******************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Marechai.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One translated <see cref="SoftwareScreenshot.Caption" /> per (<see cref="ScreenshotId" />,
|
||||
/// <see cref="LanguageCode" />) pair. English (<c>eng</c>) is treated as the identity copy of
|
||||
/// <see cref="SoftwareScreenshot.Caption" /> and is NEVER stored here — read endpoints fall back
|
||||
/// to the parent <c>Caption</c> column when no translation row exists for the requested
|
||||
/// language. The background <c>TranslationWorker</c> populates this table by calling OpenAI /
|
||||
/// NLLB; rows are append-only and are bulk-deleted whenever the canonical caption changes.
|
||||
/// </summary>
|
||||
public class SoftwareScreenshotCaptionTranslation : BaseModel<int>
|
||||
{
|
||||
public Guid ScreenshotId { get; set; }
|
||||
|
||||
[StringLength(3)]
|
||||
[Required]
|
||||
public string LanguageCode { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Caption { get; set; }
|
||||
|
||||
public virtual SoftwareScreenshot Screenshot { get; set; }
|
||||
public virtual Iso639 Language { get; set; }
|
||||
}
|
||||
@@ -113,8 +113,11 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SoftwareScreenshotDto>> GetAsync(Guid id)
|
||||
public async Task<ActionResult<SoftwareScreenshotDto>> GetAsync(Guid id, [FromQuery] string lang = null)
|
||||
{
|
||||
string langCode = LanguageResolver.Resolve(HttpContext, lang);
|
||||
bool isEnglish = string.Equals(langCode, "eng", StringComparison.Ordinal);
|
||||
|
||||
SoftwareScreenshotDto dto = await context.SoftwareScreenshots
|
||||
.Where(s => s.Id == id)
|
||||
.Select(s => new SoftwareScreenshotDto
|
||||
@@ -126,7 +129,14 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
|
||||
PlatformName = s.Platform != null ? s.Platform.Name : null,
|
||||
SoftwareVersionId = s.SoftwareVersionId,
|
||||
VersionString = s.Version != null ? s.Version.VersionString : null,
|
||||
Caption = s.Caption,
|
||||
Caption = isEnglish || s.Caption == null
|
||||
? s.Caption
|
||||
: (context.SoftwareScreenshotCaptionTranslations
|
||||
.Where(t => t.ScreenshotId == s.Id &&
|
||||
t.LanguageCode == langCode)
|
||||
.Select(t => t.Caption)
|
||||
.FirstOrDefault() ?? s.Caption),
|
||||
CanonicalCaption = s.Caption,
|
||||
OriginalExtension = s.OriginalExtension
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
@@ -139,21 +149,32 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public Task<List<SoftwareScreenshotDto>> GetAllAsync() =>
|
||||
context.SoftwareScreenshots
|
||||
.Select(s => new SoftwareScreenshotDto
|
||||
{
|
||||
Id = s.Id,
|
||||
SoftwareId = s.SoftwareId,
|
||||
SoftwareName = s.Software.Name,
|
||||
SoftwarePlatformId = s.SoftwarePlatformId,
|
||||
PlatformName = s.Platform != null ? s.Platform.Name : null,
|
||||
SoftwareVersionId = s.SoftwareVersionId,
|
||||
VersionString = s.Version != null ? s.Version.VersionString : null,
|
||||
Caption = s.Caption,
|
||||
OriginalExtension = s.OriginalExtension
|
||||
})
|
||||
.ToListAsync();
|
||||
public Task<List<SoftwareScreenshotDto>> GetAllAsync([FromQuery] string lang = null)
|
||||
{
|
||||
string langCode = LanguageResolver.Resolve(HttpContext, lang);
|
||||
bool isEnglish = string.Equals(langCode, "eng", StringComparison.Ordinal);
|
||||
|
||||
return context.SoftwareScreenshots
|
||||
.Select(s => new SoftwareScreenshotDto
|
||||
{
|
||||
Id = s.Id,
|
||||
SoftwareId = s.SoftwareId,
|
||||
SoftwareName = s.Software.Name,
|
||||
SoftwarePlatformId = s.SoftwarePlatformId,
|
||||
PlatformName = s.Platform != null ? s.Platform.Name : null,
|
||||
SoftwareVersionId = s.SoftwareVersionId,
|
||||
VersionString = s.Version != null ? s.Version.VersionString : null,
|
||||
Caption = isEnglish || s.Caption == null
|
||||
? s.Caption
|
||||
: (context.SoftwareScreenshotCaptionTranslations
|
||||
.Where(t => t.ScreenshotId == s.Id && t.LanguageCode == langCode)
|
||||
.Select(t => t.Caption)
|
||||
.FirstOrDefault() ?? s.Caption),
|
||||
CanonicalCaption = s.Caption,
|
||||
OriginalExtension = s.OriginalExtension
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[Authorize(Roles = "Admin,UberAdmin")]
|
||||
@@ -258,6 +279,7 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
|
||||
SoftwarePlatformId = model.SoftwarePlatformId,
|
||||
SoftwareVersionId = model.SoftwareVersionId,
|
||||
Caption = model.Caption,
|
||||
CanonicalCaption = model.Caption,
|
||||
OriginalExtension = model.OriginalExtension
|
||||
});
|
||||
}
|
||||
@@ -278,7 +300,24 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigurati
|
||||
|
||||
if(model is null) return NotFound();
|
||||
|
||||
model.Caption = dto.Caption;
|
||||
// Admin / suggestion edit may submit the canonical English caption via
|
||||
// DTO.CanonicalCaption (preferred) or fall back to DTO.Caption when the dialog
|
||||
// doesn't carry a separate canonical field.
|
||||
string newCaption = !string.IsNullOrEmpty(dto.CanonicalCaption) ? dto.CanonicalCaption : dto.Caption;
|
||||
string oldCaption = model.Caption;
|
||||
|
||||
// Invalidate cached translations whenever the canonical caption text changes,
|
||||
// so the background TranslationWorker re-translates on its next sweep. Comparison
|
||||
// is char-exact (Ordinal) — translation output is char-exact too, so any
|
||||
// case/whitespace edit warrants re-translation.
|
||||
if(!string.Equals(oldCaption ?? string.Empty, newCaption ?? string.Empty, StringComparison.Ordinal))
|
||||
{
|
||||
await context.SoftwareScreenshotCaptionTranslations
|
||||
.Where(t => t.ScreenshotId == id)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
model.Caption = newCaption;
|
||||
model.SoftwarePlatformId = dto.SoftwarePlatformId;
|
||||
model.SoftwareVersionId = dto.SoftwareVersionId;
|
||||
|
||||
|
||||
@@ -399,6 +399,15 @@ file class Program
|
||||
builder.Services.AddSingleton<Marechai.Translation.ITranslationProvider,
|
||||
PeopleBySoftwareRoleTranslationProvider>();
|
||||
|
||||
// Software screenshot caption translation provider — FK-link translation table keyed by
|
||||
// SoftwareScreenshot.Id (Guid). NO in-memory cache; read endpoints project the localized
|
||||
// caption via a correlated sub-query against SoftwareScreenshotCaptionTranslations with
|
||||
// English fallback. Each screenshot's caption is translated independently (no cross-
|
||||
// screenshot dedup); UpdateAsync invalidates the rows when the canonical caption text
|
||||
// changes so the worker re-translates on its next sweep.
|
||||
builder.Services.AddSingleton<Marechai.Translation.ITranslationProvider,
|
||||
SoftwareScreenshotCaptionTranslationProvider>();
|
||||
|
||||
// Background worker that fills SoftwareGenreTranslations using OpenAI (preferred) /
|
||||
// NLLB (fallback). Exits permanently if neither provider is configured. MUST be
|
||||
// registered after AddMarechaiTranslation + AddSingleton<SoftwareGenreTranslationCache>.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ License ] --------------------------------------------------------------
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright © 2003-2026 Natalia Portillo
|
||||
*******************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Marechai.Database.Models;
|
||||
using Marechai.Translation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marechai.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ITranslationProvider" /> for <see cref="SoftwareScreenshot.Caption" />. Carries
|
||||
/// NO in-memory cache singleton: read endpoints project the localized caption directly from
|
||||
/// the DB via a LEFT JOIN sub-query (the per-request RTT was already paid to fetch the
|
||||
/// screenshot row). The worker fills <see cref="SoftwareScreenshotCaptionTranslation" /> in
|
||||
/// the background so subsequent non-English requests find a row instead of falling back to
|
||||
/// the canonical English caption.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="EnsureCacheLoadedAsync" /> and <see cref="DiscoverNewItemsAsync" /> are no-ops
|
||||
/// because <see cref="TranslateMissingAsync" /> queries the DB every tick anyway with an
|
||||
/// anti-join. Captions can be very numerous (one per screenshot, no cross-screenshot dedup),
|
||||
/// so the worker batches the SaveChanges to keep the transaction moderate.
|
||||
/// </remarks>
|
||||
public sealed class SoftwareScreenshotCaptionTranslationProvider(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TranslationService translationService,
|
||||
ILogger<SoftwareScreenshotCaptionTranslationProvider> logger) : ITranslationProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of translations to accumulate before flushing to the database. Keeps the
|
||||
/// transaction window short so a worker cancellation mid-sweep doesn't lose more than
|
||||
/// this many already-translated rows.
|
||||
/// </summary>
|
||||
const int FlushBatchSize = 50;
|
||||
|
||||
public string Name => "SoftwareScreenshotCaption";
|
||||
|
||||
public Task EnsureCacheLoadedAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<int> DiscoverNewItemsAsync(CancellationToken ct) => Task.FromResult(0);
|
||||
|
||||
/// <summary>
|
||||
/// Anti-join query for screenshots whose <see cref="SoftwareScreenshot.Caption" /> is
|
||||
/// non-empty AND that lack a translation row in <paramref name="languageCode" />,
|
||||
/// translate each via <see cref="TranslationService.TranslateAsync" /> serially (preserves
|
||||
/// OpenAI rate-limit safety), then bulk-insert in <see cref="FlushBatchSize" /> chunks.
|
||||
/// Returns the number of rows inserted.
|
||||
/// </summary>
|
||||
public async Task<int> TranslateMissingAsync(string languageCode, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
|
||||
MarechaiContext ctx = scope.ServiceProvider.GetRequiredService<MarechaiContext>();
|
||||
|
||||
List<(Guid Id, string Caption)> missing = (await ctx.SoftwareScreenshots
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Caption != null && s.Caption != "" &&
|
||||
!ctx.SoftwareScreenshotCaptionTranslations
|
||||
.Any(t => t.ScreenshotId == s.Id &&
|
||||
t.LanguageCode == languageCode))
|
||||
.OrderBy(s => s.CreatedOn)
|
||||
.ThenBy(s => s.Id)
|
||||
.Select(s => new { s.Id, s.Caption })
|
||||
.ToListAsync(ct))
|
||||
.Select(x => (x.Id, x.Caption))
|
||||
.ToList();
|
||||
|
||||
if(missing.Count == 0) return 0;
|
||||
|
||||
logger.LogInformation(
|
||||
"SoftwareScreenshotCaption provider: {Count} captions missing translation into {Lang}.",
|
||||
missing.Count, languageCode);
|
||||
|
||||
// Domain hint passed to OpenAI so short label translations stay in the right register
|
||||
// (e.g. in-game / in-application screenshot captions).
|
||||
const string domainContext =
|
||||
"The text is a caption / descriptive label for an in-game or in-application screenshot of a piece of " +
|
||||
"software (e.g. 'Title screen', 'Main menu', 'Game over screen', 'Boss fight at level 3', " +
|
||||
"'Settings dialog', 'Inventory view'). Keep brand names, character names, level names, version " +
|
||||
"numbers and copyright notices unchanged.";
|
||||
|
||||
var inserted = 0;
|
||||
var batch = new List<SoftwareScreenshotCaptionTranslation>(FlushBatchSize);
|
||||
|
||||
foreach((Guid id, string englishCaption) in missing)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if(string.IsNullOrWhiteSpace(englishCaption)) continue;
|
||||
|
||||
(string translated, string error) = await translationService.TranslateAsync(
|
||||
englishCaption, languageCode, progress: null, plainText: true, domainContext: domainContext);
|
||||
|
||||
if(string.IsNullOrWhiteSpace(translated))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SoftwareScreenshotCaption provider: failed to translate screenshot {ScreenshotId} (\"{English}\") into {Lang}: {Error}",
|
||||
id, englishCaption, languageCode, error ?? "no result");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.Add(new SoftwareScreenshotCaptionTranslation
|
||||
{
|
||||
ScreenshotId = id,
|
||||
LanguageCode = languageCode,
|
||||
Caption = translated
|
||||
});
|
||||
|
||||
if(batch.Count < FlushBatchSize) continue;
|
||||
|
||||
ctx.SoftwareScreenshotCaptionTranslations.AddRange(batch);
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
inserted += batch.Count;
|
||||
batch.Clear();
|
||||
}
|
||||
|
||||
if(batch.Count > 0)
|
||||
{
|
||||
ctx.SoftwareScreenshotCaptionTranslations.AddRange(batch);
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
inserted += batch.Count;
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
}
|
||||
@@ -938,7 +938,12 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.Software.Screenshots[id].GetAsync();
|
||||
string lang = UiLanguage.GetIso639_3();
|
||||
|
||||
return await client.Software.Screenshots[id].GetAsync(config =>
|
||||
{
|
||||
config.QueryParameters.Lang = lang;
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user