diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index cdd6e9a734..0000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,60 +0,0 @@ -# GitHub Copilot Instructions for languageforge-lexbox - -## Project Overview - -This is a monorepo containing: -- **LexBox** - A web application for managing linguistic data (backend in C#/.NET, frontend in Svelte) -- **FwLite** - A lightweight FieldWorks application (MAUI desktop app) -- **FwHeadless** - A headless service for FieldWorks data processing - -## Tech Stack - -- **Backend**: .NET 9, C#, Entity Framework Core, GraphQL (Hot Chocolate) -- **Frontend**: SvelteKit, TypeScript -- **Database**: PostgreSQL -- **Infrastructure**: Docker, Kubernetes, Skaffold, Tilt - -## Development Commands - -```bash -# Backend -dotnet build backend/LexBoxApi/LexBoxApi.csproj -dotnet test - -# FwLite (Windows) -dotnet build backend/FwLite/FwLiteMaui/FwLiteMaui.csproj --framework net9.0-windows10.0.19041.0 - -# Frontend -cd frontend && pnpm dev -``` - -## Project Structure - -```text -languageforge-lexbox/ -├── backend/ -│ ├── LexBoxApi/ # Main API (ASP.NET Core + GraphQL) -│ ├── LexCore/ # Core domain models -│ ├── LexData/ # Data access layer (EF Core) -│ ├── FwLite/ # FwLite MAUI app -│ ├── FwHeadless/ # Headless FW service -│ └── Testing/ # Test projects -├── frontend/ # SvelteKit web app -└── deployment/ # K8s/Docker configs -``` - -## Important Rules - -- ✅ Use GitHub Issues for task tracking -- ✅ Use `gh` CLI for GitHub issues/PRs, not browser tools -- ✅ Use **Mermaid diagrams** for flowcharts and architecture (not ASCII art) -- ❌ Do NOT use ASCII art for diagrams (use Mermaid instead) - ---- - -**For detailed workflows and advanced features, see [AGENTS.md](../AGENTS.md)** - -**For critical code areas:** -- [FwLite/CRDT Guide](../backend/FwLite/AGENTS.md) - Data sync, model changes (HIGH RISK) -- [FwHeadless Guide](../backend/FwHeadless/AGENTS.md) - Mercurial sync, FwData processing -- [CI/CD Guide](./AGENTS.md) - Workflows, deployments, K8s diff --git a/AGENTS.md b/AGENTS.md index 68f8b2b5f6..f33b00b2ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,28 +7,14 @@ This is a monorepo containing: - **FwLite** - A lightweight FieldWorks application (MAUI desktop app) - **FwHeadless** - A headless service for FieldWorks data processing -## Tech Stack +### Tech Stack - **Backend**: .NET 9, C#, Entity Framework Core, GraphQL (Hot Chocolate) - **Frontend**: SvelteKit, TypeScript - **Database**: PostgreSQL - **Infrastructure**: Docker, Kubernetes, Skaffold, Tilt -## Development Commands - -```bash -# Backend -dotnet build backend/LexBoxApi/LexBoxApi.csproj -dotnet test - -# FwLite (Windows) -dotnet build backend/FwLite/FwLiteMaui/FwLiteMaui.csproj --framework net9.0-windows10.0.19041.0 - -# Frontend -cd frontend && pnpm dev -``` - -## Project Structure +### Structure ```text languageforge-lexbox/ @@ -39,66 +25,66 @@ languageforge-lexbox/ │ ├── FwLite/ # FwLite MAUI app │ ├── FwHeadless/ # Headless FW service │ └── Testing/ # Test projects -├── frontend/ # SvelteKit web app +├── frontend/ # Lexbox SvelteKit web app +├── frontend/viewer/ # FieldWorks Lite frontend Svelte code └── deployment/ # K8s/Docker configs ``` -**IMPORTANT: Testing Policy** -- ❌ **Do NOT run integration tests** (`dotnet test`) unless the user explicitly asks -- Integration tests require full test infrastructure (database, services) and take significant time -- Only run unit tests locally when verifying critical business logic -- User must explicitly request test runs before executing them - -### Checking GitHub Issues and PRs - -When asked to check GitHub issues or PRs, use the `gh` CLI instead of browser tools: - -```bash -# List open issues -gh issue list --limit 30 - -# List open PRs -gh pr list --limit 30 - -# View specific issue or PR -gh issue view -gh pr view -``` - -Provide an in-conversation summary highlighting: -- Urgent/critical issues (regressions, bugs, broken builds) -- Common themes or patterns -- Items needing immediate attention - -**Why CLI over browser**: Faster, less tokens, easier to scan and discuss. - ### Important Files Key documentation for this project: - `README.md` - Project overview and setup - `AGENTS.md` - You are here! Agent instructions -- `.github/copilot-instructions.md` - GitHub Copilot auto-loaded instructions - `.github/AGENTS.md` - **CI/CD and deployment guide** (workflows, K8s, Docker) - `docs/DEVELOPER-win.md` - Windows development setup - `docs/DEVELOPER-linux.md` - Linux development setup - `docs/DEVELOPER-osx.md` - macOS development setup - `backend/README.md` - Backend architecture -- `backend/FwLite/AGENTS.md` - **FwLite/CRDT critical code guide** (data loss risks!) -- `backend/FwHeadless/AGENTS.md` - **FwHeadless sync guide** +- `backend/AGENTS.md` - General backend guidelines +- `backend/LexBoxApi/AGENTS.md` - API & GraphQL specific rules +- `backend/FwLite/AGENTS.md` - **FwLite/CRDT** (Critical code! Data loss risks!) +- `backend/FwHeadless/AGENTS.md` - **FwHeadless guide** (Critical code! Data loss risks! Mercurial sync, FwData processing) +- `frontend/AGENTS.md` - General frontend/SvelteKit rules +- `frontend/viewer/AGENTS.md` - **FwLite Viewer** (Specific frontend rules) - `deployment/README.md` - Deployment and infrastructure +## Guidelines + +### Testing + +- ❌ **Do NOT run dotnet INTEGRATION tests** unless the user explicitly asks. They require full test infrastructure (database, services) which usually isn't available. +- ✅ **DO run unit tests locally** and filter to the tests that are relevant to the changes you are making. Use IDE testing tools over the cli. + ### Questions? - Check existing issues: `gh issue list --limit 30` - Look at recent commits: `git log --oneline -20` - Read the docs in `docs/` directory - Create a GitHub issue if unsure +- Ask the user to clarify + +### Pre-Flight Check + +Before implementing any change that will touch many files or is in a 🔴 **Critical** area (FwLite sync, FwHeadless) do a "Pre-Flight Check" and list every component in the chain that will be touched (e.g., MiniLcm -> LcmCrdt -> FwDataBridge -> SyncHelper). ### Important Rules -- ✅ Use GitHub Issues for task tracking +- ✅ **ALWAYS read local `AGENTS.md` files** in the directories you are working in (and their parents) before starting. +- ✅ **ALWAYS review relevant code paths** before asking clarification questions. +- ✅ New instructions in AGENTS.md files should be SUCCINCT. - ✅ Use `gh` CLI for GitHub issues/PRs, not browser tools +- ✅ When pulling PR comments with `gh` use `api`. It's the only thing that returns review comments. - ✅ Use **Mermaid diagrams** for flowcharts and architecture (not ASCII art) +- ✅ Prefer IDE diagnostics (compiler/lint errors) over CLI tools for identifying issues. Fixing these diagnostics is part of completing any instruction. - ✅ Do NOT run integration tests unless user explicitly requests -- ❌ Do NOT use ASCII art for diagrams (use Mermaid instead) +- ✅ When handling a user prompt ALWAYS ask for clarification if there are details to clarify, important decisions that must be made first or the plan sounds unwise - ❌ Do NOT git commit or git push without explicit user approval + +### 🛡️ VIGILANCE + +- ❌ **NEVER "fix" a failure** by removing assertions, commenting out code, or changing data to match a broken implementation. +- ✅ **ALWAYS fix the root cause** when a test or check fails. +- ✅ **ALWAYS double-check** that your "fix" hasn't made a check or test meaningless (e.g., asserting `expect(true).toBe(true)`). +- ✅ **Assert that E2E test user actions** e.g. (scroll, click, etc.) actually have the expected effect before proceeding further. + +If you are struggling, explain the difficulty to the user instead of cheating. **Integrity is non-negotiable.** diff --git a/backend/FwLite/AGENTS.md b/backend/FwLite/AGENTS.md index 30b10390d9..224c1ea2ff 100644 --- a/backend/FwLite/AGENTS.md +++ b/backend/FwLite/AGENTS.md @@ -30,6 +30,17 @@ dotnet test FwLiteOnly.slnf dotnet build FwLiteMaui/FwLiteMaui.csproj --framework net9.0-windows10.0.19041.0 ``` +## Generated Types (TypeScript) + +The frontend viewer uses TypeScript types and API interfaces generated from .NET using **Reinforced.Typings**. These are automatically updated when you build the **FwLiteShared** project (or any project that depends on it like `FwLiteMaui` or `FwLiteWeb`). + +```bash +# To manually update generated types: +dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj +``` + +The configuration for this lives in `FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs` and `FwLiteShared/Reinforced.Typings.settings.xml`. + ## Project Structure | Directory | Priority | Purpose | diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/EntryIndexTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/EntryIndexTests.cs new file mode 100644 index 0000000000..dbfea0dd20 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/EntryIndexTests.cs @@ -0,0 +1,12 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; + +namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; + +[Collection(ProjectLoaderFixture.Name)] +public class EntryIndexTests(ProjectLoaderFixture fixture) : EntryIndexTestsBase +{ + protected override Task NewApi() + { + return Task.FromResult(fixture.NewProjectApi("entry-index-test", "en", "en")); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index c52fba6a0f..e03fc2594e 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -918,21 +918,21 @@ public IAsyncEnumerable GetEntries( options ??= QueryOptions.Default; var entries = GetLexEntries(predicate, options); - entries = ApplySorting(options, entries, query); + entries = ApplySorting(options.Order, entries, query); entries = options.ApplyPaging(entries); return entries.ToAsyncEnumerable().Select(FromLexEntry); } - private IEnumerable ApplySorting(QueryOptions options, IEnumerable entries, string? query) + private IEnumerable ApplySorting(SortOptions order, IEnumerable entries, string? query) { - var sortWs = GetWritingSystemHandle(options.Order.WritingSystem, WritingSystemType.Vernacular); - if (options.Order.Field == SortField.SearchRelevance) + var sortWs = GetWritingSystemHandle(order.WritingSystem, WritingSystemType.Vernacular); + if (order.Field == SortField.SearchRelevance) { - return entries.ApplyRoughBestMatchOrder(options.Order, sortWs, query); + return entries.ApplyRoughBestMatchOrder(order, sortWs, query); } - return options.ApplyOrder(entries, e => e.LexEntryHeadword(sortWs)); + return order.ApplyOrder(entries, e => e.LexEntryHeadword(sortWs)); } public IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null) @@ -955,6 +955,25 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options return Task.FromResult(lexEntry is null ? null : FromLexEntry(lexEntry)); } + public Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + var predicate = EntrySearchPredicate(query); + var entries = GetLexEntries(predicate, options); + entries = ApplySorting(options?.Order ?? SortOptions.Default, entries, query); + + var rowIndex = 0; + foreach (var entry in entries) + { + if (entry.Guid == entryId) + { + return Task.FromResult(rowIndex); + } + rowIndex++; + } + + return Task.FromResult(-1); + } + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { options ??= CreateEntryOptions.Everything; diff --git a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs index 019208f154..8abd0d754a 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs @@ -32,6 +32,7 @@ public class LexEntryFilterMapProvider : EntryFilterMapProvider public override Expression> EntryCitationForm => (entry, ws) => entry.PickText(entry.CitationForm, ws); public override Expression> EntryLiteralMeaning => (entry, ws) => entry.PickText(entry.LiteralMeaning, ws); + public override Expression> EntryMorphType => e => LcmHelpers.FromLcmMorphType(e.PrimaryMorphType); public override Expression> EntryComplexFormTypes => e => EmptyToNull(e.ComplexFormEntryRefs.SelectMany(r => r.ComplexEntryTypesRS)); public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToNull; } diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index 3daf61bddd..48a6967d98 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -95,6 +95,12 @@ public Task CountEntries(string? query, FilterQueryOptions? options) return Task.Run(() => _wrappedApi.CountEntries(query, options)); } + [JSInvokable] + public Task GetEntryIndex(Guid id, string? query, IndexQueryOptions? options) + { + return Task.Run(() => _wrappedApi.GetEntryIndex(id, query, options)); + } + [JSInvokable] public Task GetEntries(QueryOptions? options = null) { diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 960ef578fe..a76647607c 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -119,6 +119,7 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) builder.ExportAsInterfaces([ typeof(QueryOptions), typeof(FilterQueryOptions), + typeof(IndexQueryOptions), typeof(SortOptions), typeof(ExemplarOptions), typeof(EntryFilter), diff --git a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs index 7f6fd036af..272bd07908 100644 --- a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs @@ -99,6 +99,7 @@ await projectProvider.OpenProject(project, context.HttpContext.RequestServices) api.MapGet("/entries", MiniLcm.GetEntries); api.MapGet("/entries/{search}", MiniLcm.SearchEntries); api.MapGet("/entry/{id:Guid}", MiniLcm.GetEntry); + api.MapGet("/entry/{id:Guid}/index", MiniLcm.GetEntryIndex); api.MapGet("/parts-of-speech", MiniLcm.GetPartsOfSpeech); api.MapGet("/semantic-domains", MiniLcm.GetSemanticDomains); api.MapGet("/publications", MiniLcm.GetPublications); @@ -137,6 +138,15 @@ public static IAsyncEnumerable SearchEntries([FromServices] MiniLcmHolder return api.GetEntry(id); } + public static Task GetEntryIndex( + Guid id, + [AsParameters] MiniLcmQueryOptions options, + [FromServices] MiniLcmHolder holder) + { + var api = holder.MiniLcmApi; + return api.GetEntryIndex(id, null, options.ToIndexQueryOptions()); + } + public static IAsyncEnumerable GetPartsOfSpeech([FromServices] MiniLcmHolder holder) { var api = holder.MiniLcmApi; @@ -185,6 +195,19 @@ public QueryOptions ToQueryOptions() string.IsNullOrEmpty(GridifyFilter) ? null : new EntryFilter {GridifyFilter = GridifyFilter}); } + public IndexQueryOptions ToIndexQueryOptions() + { + ExemplarOptions? exemplarOptions = string.IsNullOrEmpty(ExemplarValue) || ExemplarWritingSystem is null + ? null + : new(ExemplarValue, ExemplarWritingSystem); + var sortField = SortField ?? SortOptions.Default.Field; + return new IndexQueryOptions(new SortOptions(sortField, + SortWritingSystem ?? SortOptions.Default.WritingSystem, + Ascending ?? SortOptions.Default.Ascending), + exemplarOptions, + string.IsNullOrEmpty(GridifyFilter) ? null : new EntryFilter {GridifyFilter = GridifyFilter}); + } + public SortField? SortField { get; set; } = SortOptions.Default.Field; [DefaultValue(SortOptions.DefaultWritingSystem)] diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/EntryIndexTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/EntryIndexTests.cs new file mode 100644 index 0000000000..d7badf3995 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/EntryIndexTests.cs @@ -0,0 +1,26 @@ +using Xunit.Abstractions; + +namespace LcmCrdt.Tests.MiniLcmTests; + +public class EntryIndexTests(ITestOutputHelper output) : EntryIndexTestsBase +{ + private readonly MiniLcmApiFixture _fixture = new(); + + public override async Task InitializeAsync() + { + _fixture.LogTo(output); + await _fixture.InitializeAsync(); + await base.InitializeAsync(); + } + + protected override Task NewApi() + { + return Task.FromResult(_fixture.Api); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _fixture.DisposeAsync(); + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index cfeea8fc98..2c80826f03 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -405,6 +405,12 @@ public async IAsyncEnumerable SearchEntries(string? query, QueryOptions? return await repo.GetEntry(id); } + public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetEntryIndex(entryId, query, options); + } + public async Task BulkCreateEntries(IAsyncEnumerable entries) { await using var repo = await repoFactory.CreateRepoAsync(); diff --git a/backend/FwLite/LcmCrdt/Data/CustomSqliteFunctionInterceptor.cs b/backend/FwLite/LcmCrdt/Data/CustomSqliteFunctionInterceptor.cs index 0f9528f914..661cb588e3 100644 --- a/backend/FwLite/LcmCrdt/Data/CustomSqliteFunctionInterceptor.cs +++ b/backend/FwLite/LcmCrdt/Data/CustomSqliteFunctionInterceptor.cs @@ -1,20 +1,60 @@ using System.Data.Common; using System.Globalization; -using System.Runtime.CompilerServices; using System.Text; +using LinqToDB.Interceptors; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore.Diagnostics; -using MiniLcm.Culture; namespace LcmCrdt.Data; -public class CustomSqliteFunctionInterceptor : IDbConnectionInterceptor +public class CustomSqliteFunctionInterceptor : IDbConnectionInterceptor, IConnectionInterceptor { public const string ContainsFunction = "contains"; public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) { - var sqliteConnection = (SqliteConnection)connection; + ConnectionOpened(connection); + } + + public Task ConnectionOpenedAsync(DbConnection connection, + ConnectionEndEventData eventData, + CancellationToken cancellationToken = default) + { + ConnectionOpened(connection); + return Task.CompletedTask; + } + + public void ConnectionOpening(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection) + { + // We register the function after connection opens, not before + } + + public Task ConnectionOpeningAsync(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public void ConnectionOpened(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection) + { + ConnectionOpened(connection); + } + + public Task ConnectionOpenedAsync(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection, CancellationToken cancellationToken) + { + ConnectionOpened(connection); + return Task.CompletedTask; + } + + private void ConnectionOpened(DbConnection connection) + { + if (connection is SqliteConnection sqliteConnection) + { + RegisterContainsFunction(sqliteConnection); + } + } + + public static void RegisterContainsFunction(SqliteConnection sqliteConnection) + { //creates a new function that can be used in queries sqliteConnection.CreateFunction(ContainsFunction, //in sqlite strings are byte arrays, so we can avoid allocating strings by using spans @@ -50,12 +90,4 @@ private static bool ContainsDiacritic(in ReadOnlySpan value) } return hasAccent; } - - public Task ConnectionOpenedAsync(DbConnection connection, - ConnectionEndEventData eventData, - CancellationToken cancellationToken = new CancellationToken()) - { - ConnectionOpened(connection, eventData); - return Task.CompletedTask; - } } diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index 20233e1038..602a2e4f1c 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -4,6 +4,7 @@ using LcmCrdt.FullTextSearch; using LcmCrdt.Utils; using LinqToDB; +using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -61,7 +62,7 @@ public void Dispose() } - public IQueryable Entries => dbContext.Entries; + public IQueryable Entries => dbContext.Entries.ToLinqToDB(); public IQueryable ComplexFormComponents => dbContext.ComplexFormComponents; public IQueryable ComplexFormTypes => dbContext.ComplexFormTypes; public IQueryable Senses => dbContext.Senses; @@ -128,12 +129,8 @@ public async IAsyncEnumerable GetEntries( string? query = null, QueryOptions? options = null) { - options = await EnsureWritingSystemIsPopulated(options ??= QueryOptions.Default); - - var queryable = Entries; - (queryable, var sortingHandled) = await FilterEntries(queryable, query, options, options.Order); - if (!sortingHandled) - queryable = await ApplySorting(queryable, options, query); + IQueryable queryable; + (queryable, options) = await FilterAndSortEntries(query, options ?? QueryOptions.Default); queryable = queryable .LoadWith(e => e.Senses) @@ -155,6 +152,20 @@ public async IAsyncEnumerable GetEntries( } } + private async Task<(IQueryable queryable, QueryOptions options)> FilterAndSortEntries( + string? query, + QueryOptions options) + { + options = await EnsureWritingSystemIsPopulated(options); + + var queryable = Entries; + var (filteredQuery, sortingHandled) = await FilterEntries(queryable, query, options, options.Order); + if (!sortingHandled) + filteredQuery = await ApplySorting(filteredQuery, options, query); + + return (filteredQuery, options); + } + private async Task EnsureWritingSystemIsPopulated(QueryOptions queryOptions) { if (queryOptions.Order.WritingSystem != default) return queryOptions; @@ -208,22 +219,20 @@ private async Task EnsureWritingSystemIsPopulated(QueryOptions que return (queryable, sortingHandled); } - private async ValueTask> ApplySorting(IQueryable queryable, QueryOptions options, string? query = null) + private ValueTask> ApplySorting(IQueryable queryable, QueryOptions options, string? query = null) { if (options.Order.WritingSystem == default) throw new ArgumentException("Sorting writing system must be specified", nameof(options)); var wsId = options.Order.WritingSystem; - switch (options.Order.Field) + IQueryable result = options.Order.Field switch { - case SortField.SearchRelevance: - return queryable.ApplyRoughBestMatchOrder(options.Order, query); - case SortField.Headword: - var ordered = options.ApplyOrder(queryable, e => e.Headword(wsId).CollateUnicode(wsId)); - return ordered.ThenBy(e => e.Id); - default: - throw new ArgumentOutOfRangeException(nameof(options), "sort field unknown " + options.Order.Field); - } + SortField.SearchRelevance => queryable.ApplyRoughBestMatchOrder(options.Order, query), + SortField.Headword => + options.ApplyOrder(queryable, e => e.Headword(wsId).CollateUnicode(wsId)).ThenBy(e => e.Id), + _ => throw new ArgumentOutOfRangeException(nameof(options), "sort field unknown " + options.Order.Field) + }; + return new ValueTask>(result); } public async Task GetEntry(Guid id) @@ -262,6 +271,26 @@ private async ValueTask> ApplySorting(IQueryable querya return exampleSentence; } + public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + var queryOptions = new QueryOptions( + options?.Order ?? QueryOptions.Default.Order, + options?.Exemplar, + QueryOptions.QueryAll, + 0, + options?.Filter + ); + + IQueryable queryable; + (queryable, queryOptions) = await FilterAndSortEntries(query, queryOptions); + + // SQLite's ROW_NUMBER() seems to require ORDER BY in the OVER clause - it cannot inherit from the query. + // (AI tried a billion things) + // This is efficient for virtual scrolling since we only select IDs, not full entities. + var sortedIds = await queryable.Select(e => e.Id).ToListAsyncLinqToDB(); + return sortedIds.IndexOf(entryId); + } + public async Task GetPublication(Guid publicationId) { var publication = await AsyncExtensions.SingleOrDefaultAsync(Publications diff --git a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs index fc732bdd50..c2f9498e45 100644 --- a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs +++ b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs @@ -2,6 +2,7 @@ using System.Data.Common; using System.Globalization; using System.Text; +using LinqToDB.Interceptors; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -10,7 +11,7 @@ namespace LcmCrdt.Data; -public class SetupCollationInterceptor(IMemoryCache cache, IMiniLcmCultureProvider cultureProvider) : IDbConnectionInterceptor, ISaveChangesInterceptor +public class SetupCollationInterceptor(IMemoryCache cache, IMiniLcmCultureProvider cultureProvider) : IDbConnectionInterceptor, ISaveChangesInterceptor, IConnectionInterceptor { private static string? WsTableName = null; private WritingSystem[] GetWritingSystems(LcmCrdtDbContext dbContext, DbConnection connection) @@ -53,14 +54,9 @@ private void InvalidateWritingSystemsCache(DbConnection connection) cache.Remove(CacheKey(connection)); } - public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + private void SetupCommonCollations(SqliteConnection sqliteConnection, WritingSystem[]? writingSystems = null) { - var context = (LcmCrdtDbContext?)eventData.Context; - if (context is null) throw new InvalidOperationException("context is null"); - var sqliteConnection = (SqliteConnection)connection; - SetupCollations(sqliteConnection, GetWritingSystems(context, connection)); - - //setup general use collation + // Setup general use collation (used by all queries) sqliteConnection.CreateCollation(SqlSortingExtensions.CollateUnicodeNoCase, CultureInfo.CurrentCulture.CompareInfo, (compareInfo, x, y) => @@ -71,6 +67,20 @@ public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eve // When case-insensitively equal, sort lowercase before uppercase return compareInfo.Compare(x, y, CompareOptions.None); }); + + // Setup writing system specific collations if available + if (writingSystems is not null) + { + SetupCollations(sqliteConnection, writingSystems); + } + } + + public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + var context = (LcmCrdtDbContext?)eventData.Context; + if (context is null) throw new InvalidOperationException("context is null"); + var sqliteConnection = (SqliteConnection)connection; + SetupCommonCollations(sqliteConnection, GetWritingSystems(context, connection)); } public Task ConnectionOpenedAsync(DbConnection connection, @@ -81,6 +91,33 @@ public Task ConnectionOpenedAsync(DbConnection connection, return Task.CompletedTask; } + // LinqToDB interface + public void ConnectionOpening(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection) + { + // Setup happens after connection opens + } + + public Task ConnectionOpeningAsync(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public void ConnectionOpened(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection) + { + if (connection is not SqliteConnection sqliteConnection) return; + + // Only setup basic collation - writing system collations come from EF Core path + // Note: Collations persist on the connection, so if EF already opened this connection, + // this is redundant but harmless. SQLite allows re-registering collations. + SetupCommonCollations(sqliteConnection); + } + + public Task ConnectionOpenedAsync(LinqToDB.Interceptors.ConnectionEventData eventData, DbConnection connection, CancellationToken cancellationToken) + { + ConnectionOpened(eventData, connection); + return Task.CompletedTask; + } + public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { UpdateCollationsOnSave(eventData.Context); diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 5aa3a06b3f..a62d909c9c 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -27,6 +27,7 @@ public class EntryFilterMapProvider : EntryFilterMapProvider public override Expression> EntryLexemeForm => (entry, ws) => Json.Value(entry.LexemeForm, ms => ms[ws])!; public override Expression> EntryCitationForm => (entry, ws) => Json.Value(entry.CitationForm, ms => ms[ws])!; public override Expression> EntryLiteralMeaning => (entry, ws) => Json.Value(entry.LiteralMeaning, ms => ms[ws])!.GetPlainText(); + public override Expression> EntryMorphType => e => e.MorphType; public override Expression> EntryComplexFormTypes => e => e.ComplexFormTypes; public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList; } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 043489a6ec..b5ddfb578b 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -121,12 +121,22 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption new DataParameter { Value = id.Code, DataType = DataType.Text }); optionsBuilder.AddMappingSchema(mappingSchema); optionsBuilder.AddCustomOptions(options => options.UseSQLiteMicrosoft()); + + // Register read-relevant interceptors for LinqToDB + var sqliteFunctionInterceptor = new CustomSqliteFunctionInterceptor(); + var collationInterceptor = provider.GetRequiredService(); + optionsBuilder.AddInterceptor(sqliteFunctionInterceptor); + optionsBuilder.AddInterceptor(collationInterceptor); + var loggerFactory = provider.GetService(); if (loggerFactory is not null) optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory)); }); + // Register interceptors for EF Core builder.AddInterceptors(new CustomSqliteFunctionInterceptor(), provider.GetRequiredService()); + + // UpdateEntrySearchTableInterceptor is write-only (updates FTS table), so only register with EF Core var updateSearchTableInterceptor = provider.GetService(); if (updateSearchTableInterceptor is not null) builder.AddInterceptors(updateSearchTableInterceptor); diff --git a/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs b/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs new file mode 100644 index 0000000000..e994947c65 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs @@ -0,0 +1,169 @@ +using MiniLcm.Filtering; + +namespace MiniLcm.Tests; + +/// +/// Tests for GetEntryIndex API. +/// This API is critical for finding the position of an entry in a sorted/filtered list for virtual scrolling. +/// +public abstract class EntryIndexTestsBase : MiniLcmTestBase +{ + private readonly Guid appleId = Guid.NewGuid(); + private readonly Guid bananaId = Guid.NewGuid(); + private readonly Guid kiwiId = Guid.NewGuid(); + private readonly Guid orangeId = Guid.NewGuid(); + private readonly Guid peachId = Guid.NewGuid(); + + private const string Apple = "Apple"; + private const string Banana = "Banana"; + private const string Kiwi = "Kiwi"; + private const string Orange = "Orange"; + private const string Peach = "Peach"; + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await Api.CreateEntry(new Entry { Id = appleId, LexemeForm = { { "en", Apple } } }); + await Api.CreateEntry(new Entry { Id = bananaId, LexemeForm = { { "en", Banana } } }); + await Api.CreateEntry(new Entry { Id = kiwiId, LexemeForm = { { "en", Kiwi } } }); + await Api.CreateEntry(new Entry { Id = orangeId, LexemeForm = { { "en", Orange } } }); + await Api.CreateEntry(new Entry { Id = peachId, LexemeForm = { { "en", Peach } } }); + } + + [Fact] + public async Task GetEntryIndex_FirstEntry_ReturnsZero() + { + var result = await Api.GetEntryIndex(appleId); + + result.Should().Be(0); + } + + [Fact] + public async Task GetEntryIndex_MiddleEntry_ReturnsCorrectIndex() + { + var result = await Api.GetEntryIndex(kiwiId); + + result.Should().Be(2); + } + + [Fact] + public async Task GetEntryIndex_LastEntry_ReturnsLastIndex() + { + var result = await Api.GetEntryIndex(peachId); + + result.Should().Be(4); + } + + [Fact] + public async Task GetEntryIndex_NonExistentEntry_ReturnsNegativeOne() + { + var nonExistentId = Guid.NewGuid(); + + var result = await Api.GetEntryIndex(nonExistentId); + + result.Should().Be(-1); + } + + [Fact] + public async Task GetEntryIndex_WithCustomSort_ReturnsCorrectIndex() + { + // Sort descending + var options = new IndexQueryOptions(new SortOptions(SortField.Headword, "en", false)); + + // Order: Peach, Orange, Kiwi, Banana, Apple + (await Api.GetEntryIndex(peachId, null, options)).Should().Be(0); + (await Api.GetEntryIndex(orangeId, null, options)).Should().Be(1); + (await Api.GetEntryIndex(kiwiId, null, options)).Should().Be(2); + (await Api.GetEntryIndex(bananaId, null, options)).Should().Be(3); + (await Api.GetEntryIndex(appleId, null, options)).Should().Be(4); + } + + [Fact] + public async Task GetEntryIndex_LargeList_ReturnsCorrectIndex() + { + var entryIds = new List(); + for (var i = 0; i < 1005; i++) + { + var id = Guid.NewGuid(); + entryIds.Add(id); + // Ensure deterministic sort (e.g., 0000 Entry, 0001 Entry, ...) + // We use a prefix to ensure these come before the standard entries (Apple, Banana, etc) + await Api.CreateEntry(new Entry { Id = id, LexemeForm = { { "en", $"{i:D4} Entry" } } }); + } + + (await Api.GetEntryIndex(entryIds[0])).Should().Be(0); + (await Api.GetEntryIndex(entryIds[500])).Should().Be(500); + (await Api.GetEntryIndex(entryIds[1000])).Should().Be(1000); + (await Api.GetEntryIndex(entryIds[1004])).Should().Be(1004); + } + + [Fact] + public async Task GetEntryIndex_WithShortQuery_ReturnsCorrectIndex() + { + // Query "Ki" is < 3 characters, should use simple search filter + var result = await Api.GetEntryIndex(kiwiId, "Ki"); + + // "Kiwi" should be the only match, index 0 + result.Should().Be(0); + } + + [Fact] + public async Task GetEntryIndex_WithLongQuery_ReturnsCorrectIndex() + { + // Query "Orange" is >= 3 characters, should use FTS + var result = await Api.GetEntryIndex(orangeId, "Orange"); + + // "Orange" should be found, index 0 + result.Should().Be(0); + } + + [Fact] + public async Task GetEntryIndex_WithGridifyFilter_ReturnsCorrectIndex() + { + // Use Gridify filter to find "banana" + var options = new IndexQueryOptions(Filter: new EntryFilter { GridifyFilter = "LexemeForm[en]=Banana" }); + + var result = await Api.GetEntryIndex(bananaId, null, options); + + result.Should().Be(0); + } + + [Fact] + public async Task GetEntryIndex_WithFilterAndSort_ReturnsCorrectIndex() + { + var options = new IndexQueryOptions(new SortOptions(SortField.SearchRelevance, "en", true)); + + (await Api.GetEntryIndex(appleId, "a", options)).Should().Be(0); + (await Api.GetEntryIndex(peachId, "a", options)).Should().Be(1); + (await Api.GetEntryIndex(bananaId, "a", options)).Should().Be(2); + (await Api.GetEntryIndex(orangeId, "a", options)).Should().Be(3); + + // "Kiwi" shouldn't be in the results at all + (await Api.GetEntryIndex(kiwiId, "a", options)).Should().Be(-1); + } + + [Fact] + public async Task GetEntryIndex_WithFTSFilterAndSort_ReturnsCorrectIndex() + { + // Add some more entries to make search more interesting + var applePieId = Guid.NewGuid(); + var pineappleId = Guid.NewGuid(); + await Api.CreateEntry(new Entry { Id = applePieId, LexemeForm = { { "en", "Apple Pie" } } }); + await Api.CreateEntry(new Entry { Id = pineappleId, LexemeForm = { { "en", "Pineapple" } } }); + + // Query "Apple" (>= 3 chars, uses SearchService/FTS) + // Matches: Apple (len 5), Apple Pie (len 9), Pineapple (len 9) + // Sorting: + // 1. Headword contains "Apple": All match. + // 2. Length: Apple (5), Apple Pie (9), Pineapple (9) + // 3. Alphabetical (for 9-len): Apple Pie, Pineapple + var options = new IndexQueryOptions(new SortOptions(SortField.SearchRelevance, "en", true)); + + (await Api.GetEntryIndex(appleId, "Apple", options)).Should().Be(0); + (await Api.GetEntryIndex(applePieId, "Apple", options)).Should().Be(1); + (await Api.GetEntryIndex(pineappleId, "Apple", options)).Should().Be(2); + + // "Banana" shouldn't be in the results + (await Api.GetEntryIndex(bananaId, "Apple", options)).Should().Be(-1); + } +} diff --git a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs index 117ab99970..224002899e 100644 --- a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs @@ -226,6 +226,24 @@ public async Task CanFilterToMissingComplexFormTypesWithEmptyArray() results.Select(e => e.LexemeForm["en"]).Distinct().Should().BeEquivalentTo(Apple, Banana, Kiwi, Null_LexemeForm); } + [Fact] + public async Task CanFilterByMorphTypeSingleType() + { + // This test ensures we can filter by a single morph type + var results = await Api.GetEntries(new(Filter: new() { GridifyFilter = "MorphType=Root" })).ToArrayAsync(); + // Root is not set on any of our test entries (they all default to Stem), so should be empty + results.Should().BeEmpty(); + } + + [Fact] + public async Task CanFilterByMorphTypeTwoTypes() + { + // This test ensures we can filter by multiple morph types using OR syntax + var results = await Api.GetEntries(new(Filter: new() { GridifyFilter = "MorphType=Root|MorphType=Stem" })).ToArrayAsync(); + // All our test entries default to Stem, so we should get all of them + results.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(Apple, Peach, Banana, Kiwi, Null_LexemeForm); + } + [Fact] public async Task CanFilterLexemeForm() { diff --git a/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs b/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs index f1f332961b..a1688e3911 100644 --- a/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs +++ b/backend/FwLite/MiniLcm/Filtering/EntryFilter.cs @@ -26,6 +26,7 @@ public static GridifyMapper NewMapper(EntryFilterMapProvider provider) mapper.AddMap(nameof(Entry.LexemeForm), provider.EntryLexemeForm!); mapper.AddMap(nameof(Entry.CitationForm), provider.EntryCitationForm!); mapper.AddMap(nameof(Entry.LiteralMeaning), provider.EntryLiteralMeaning!); + mapper.AddMap(nameof(Entry.MorphType), provider.EntryMorphType); mapper.AddMap(nameof(Entry.ComplexFormTypes), provider.EntryComplexFormTypes, provider.EntryComplexFormTypesConverter); return mapper; } diff --git a/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs b/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs index 00be759cde..58967af5f3 100644 --- a/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs +++ b/backend/FwLite/MiniLcm/Filtering/EntryFilterMapProvider.cs @@ -19,6 +19,7 @@ public abstract class EntryFilterMapProvider public abstract Expression> EntryLexemeForm { get; } public abstract Expression> EntryCitationForm { get; } public abstract Expression> EntryLiteralMeaning { get; } + public abstract Expression> EntryMorphType { get; } public abstract Expression> EntryComplexFormTypes { get; } public virtual Func? EntryComplexFormTypesConverter { get; } = null; } diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 7e396956bb..db89753c37 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -27,6 +27,11 @@ public interface IMiniLcmReadApi Task GetPublication(Guid id); Task GetSemanticDomain(Guid id); Task GetExampleSentence(Guid entryId, Guid senseId, Guid id); + /// + /// Get the index of an entry within the sorted/filtered entry list. + /// Returns -1 if the entry is not found. + /// + Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null); Task GetFileStream(MediaUri mediaUri) { @@ -34,12 +39,26 @@ Task GetFileStream(MediaUri mediaUri) } } +public record IndexQueryOptions( + SortOptions? Order = null, + ExemplarOptions? Exemplar = null, + EntryFilter? Filter = null) : FilterQueryOptions(Exemplar, Filter) +{ + public static new IndexQueryOptions Default { get; } = new(); + public SortOptions Order { get; init; } = Order ?? SortOptions.Default; + + public override IndexQueryOptions Normalized(NormalizationForm form) + { + return new(Order, Exemplar?.Normalized(form), Filter?.Normalized(form)); + } +} + public record FilterQueryOptions( ExemplarOptions? Exemplar = null, EntryFilter? Filter = null) { public static FilterQueryOptions Default { get; } = new(); - public bool HasFilter => Filter is {GridifyFilter.Length: > 0 } || Exemplar is {Value.Length: > 0}; + public bool HasFilter => Filter is { GridifyFilter.Length: > 0 } || Exemplar is { Value.Length: > 0 }; public virtual FilterQueryOptions Normalized(NormalizationForm form) { return new(Exemplar?.Normalized(form), Filter?.Normalized(form)); @@ -81,26 +100,12 @@ public IQueryable ApplyPaging(IQueryable queryable) public IOrderedEnumerable ApplyOrder(IEnumerable enumerable, Func orderFunc) { - if (Order.Ascending) - { - return enumerable.OrderBy(orderFunc); - } - else - { - return enumerable.OrderByDescending(orderFunc); - } + return Order.ApplyOrder(enumerable, orderFunc); } public IOrderedQueryable ApplyOrder(IQueryable enumerable, Expression> orderFunc) { - if (Order.Ascending) - { - return enumerable.OrderBy(orderFunc); - } - else - { - return enumerable.OrderByDescending(orderFunc); - } + return Order.ApplyOrder(enumerable, orderFunc); } } @@ -108,6 +113,16 @@ public record SortOptions(SortField Field, WritingSystemId WritingSystem = defau { public const string DefaultWritingSystem = "default"; public static SortOptions Default { get; } = new(SortField.Headword, DefaultWritingSystem); + + public IOrderedEnumerable ApplyOrder(IEnumerable enumerable, Func orderFunc) + { + return Ascending ? enumerable.OrderBy(orderFunc) : enumerable.OrderByDescending(orderFunc); + } + + public IOrderedQueryable ApplyOrder(IQueryable enumerable, Expression> orderFunc) + { + return Ascending ? enumerable.OrderBy(orderFunc) : enumerable.OrderByDescending(orderFunc); + } } public record ExemplarOptions(string Value, WritingSystemId WritingSystem) diff --git a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiStringNormalizationWrapper.cs b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiStringNormalizationWrapper.cs index 1204dc2a11..ea756ef69d 100644 --- a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiStringNormalizationWrapper.cs +++ b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiStringNormalizationWrapper.cs @@ -39,6 +39,11 @@ public IAsyncEnumerable GetEntries(QueryOptions? options = null) return _api.GetEntries(options?.Normalized(Form)); } + public Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + return _api.GetEntryIndex(entryId, query?.Normalize(Form), options?.Normalized(Form)); + } + void IDisposable.Dispose() { } diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index 0974cc7923..1b961a15e9 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -424,4 +424,9 @@ private static SemanticDomain ToSemanticDomain(Entities.OptionListItem item) if (exampleSentence is null) return null; return ToExampleSentence(sense.Guid, exampleSentence); } + + public Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + throw new NotImplementedException(); + } } diff --git a/frontend/viewer/.gitignore b/frontend/viewer/.gitignore index 7e0b04f8df..1fa96a2456 100644 --- a/frontend/viewer/.gitignore +++ b/frontend/viewer/.gitignore @@ -26,4 +26,4 @@ html-test-results *storybook.log storybook-static -"screenshots/" +screenshots/ diff --git a/frontend/viewer/AGENTS.md b/frontend/viewer/AGENTS.md index 90f9e86a4d..f1a261b625 100644 --- a/frontend/viewer/AGENTS.md +++ b/frontend/viewer/AGENTS.md @@ -15,6 +15,35 @@ pnpm install pnpm run dev ``` +### Generated .NET Types + +This project depends on TypeScript types and API interfaces generated from .NET (via `Reinforced.Typings`). If you change .NET models or `JSInvokable` APIs, you must rebuild the backend to update these types. + +```bash +# From repo root +dotnet build backend/FwLite/FwLiteShared/FwLiteShared.csproj +``` + +The generated files are located in `src/lib/generated-types/`. + +### E2E Testing (Playwright) + +To run E2E tests for the viewer: +```bash +# From frontend/viewer/ directory +# Automatically starts dev server if needed +# For debugging e.g. with Chrome MCP: dev server/demo project will be available at port 5173 & path /testing/project-view/browse + +# Filter by test name (the ONLY RIGHT choice if testing specific features or changes) e.g. +task playwright-test-standalone -- entries-list + +# All tests +task playwright-test-standalone + +# In UI mode +task playwright-test-standalone -- entries-list --ui +``` + ## Tech Stack - **Framework**: SvelteKit + Vite diff --git a/frontend/viewer/Taskfile.yml b/frontend/viewer/Taskfile.yml index af04e77550..d9faef2d29 100644 --- a/frontend/viewer/Taskfile.yml +++ b/frontend/viewer/Taskfile.yml @@ -46,6 +46,7 @@ tasks: desc: 'runs playwright tests against already running server' cmd: pnpm run test:playwright {{.CLI_ARGS}} playwright-test-standalone: + aliases: [pts] desc: 'runs playwright tests and runs dev automatically, run ui mode by calling with -- --ui or use --update-snapshots' env: AUTO_START_SERVER: true diff --git a/frontend/viewer/eslint.config.js b/frontend/viewer/eslint.config.js index 240d3eeeb2..0b2770dc00 100644 --- a/frontend/viewer/eslint.config.js +++ b/frontend/viewer/eslint.config.js @@ -67,6 +67,11 @@ export default [ 'format': ['camelCase', 'UPPER_CASE'], 'leadingUnderscore': 'allow', }, + { + 'selector': 'classProperty', + 'modifiers': ['static', 'readonly'], + 'format': ['camelCase', 'UPPER_CASE'], + }, { 'selector': ['typeLike', 'enumMember'], 'format': ['PascalCase'], diff --git a/frontend/viewer/playwright.config.ts b/frontend/viewer/playwright.config.ts index 094f0c9d5e..99f0384271 100644 --- a/frontend/viewer/playwright.config.ts +++ b/frontend/viewer/playwright.config.ts @@ -1,5 +1,6 @@ -import { defineConfig, devices, type ReporterDescription } from '@playwright/test'; +import {defineConfig, devices, type ReporterDescription} from '@playwright/test'; import * as testEnv from '../tests/envVars'; + const vitePort = '5173'; const dotnetPort = '5137'; const autoStartServer = process.env.AUTO_START_SERVER ? Boolean(process.env.AUTO_START_SERVER) : false; diff --git a/frontend/viewer/src/lib/components/Delayed.svelte b/frontend/viewer/src/lib/components/Delayed.svelte new file mode 100644 index 0000000000..9f72d1982c --- /dev/null +++ b/frontend/viewer/src/lib/components/Delayed.svelte @@ -0,0 +1,104 @@ + + + +{@render children(state)} diff --git a/frontend/viewer/src/lib/components/ListItem.svelte b/frontend/viewer/src/lib/components/ListItem.svelte index a690c04a20..097b8693b1 100644 --- a/frontend/viewer/src/lib/components/ListItem.svelte +++ b/frontend/viewer/src/lib/components/ListItem.svelte @@ -34,6 +34,7 @@