diff --git a/.gitignore b/.gitignore index a1c9a11..7a18376 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ __pycache__ *.pyc *.pyo *.pyd +.cache +.dotnet_home +.nuget +.pip +.pydeps /build /dist .pytest_cache/ @@ -14,4 +19,11 @@ __pycache__ /data /assets /docs/build -/samples/bishpls.lmd \ No newline at end of file +/samples/bishpls.lmd +/scripts +bin/ +obj/ +AGENTS.md +communication.lmd +devtool.json +untitled4.lmd diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiContracts.cs b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiContracts.cs new file mode 100644 index 0000000..a793b5c --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiContracts.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; +using LyricFlow.Core.Dtos; + +namespace LyricFlow.Backend.Api; + +public record SaveSnapshotRequest( + [property: JsonPropertyName("file_path")] string FilePath, + [property: JsonPropertyName("content")] string Content +); + +public record SaveScratchpadRequest( + [property: JsonPropertyName("project_id")] string ProjectId, + [property: JsonPropertyName("content")] string Content +); + +public record AnalyzeRequest([property: JsonPropertyName("text")] string Text); +public record ProjectReadRequest([property: JsonPropertyName("project_file")] string ProjectFile); +public record ProjectWriteRequest( + [property: JsonPropertyName("project_file")] string ProjectFile, + [property: JsonPropertyName("state")] ProjectStateDto State +); +public record SessionLoadRequest([property: JsonPropertyName("workspace_root")] string? WorkspaceRoot); +public record SessionSaveRequest( + [property: JsonPropertyName("workspace_root")] string? WorkspaceRoot, + [property: JsonPropertyName("snapshots")] List Snapshots +); +public record SessionClearRequest([property: JsonPropertyName("workspace_root")] string? WorkspaceRoot); +public record FileReadRequest([property: JsonPropertyName("path")] string Path); +public record FileWriteRequest( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("content")] string Content +); +public record FileCreateRequest( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("is_directory")] bool IsDirectory +); +public record FileRenameRequest( + [property: JsonPropertyName("old_path")] string OldPath, + [property: JsonPropertyName("new_path")] string NewPath, + [property: JsonPropertyName("root_path")] string? RootPath +); +public record FileDeleteRequest( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("root_path")] string? RootPath +); diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiJsonSerializerContext.cs b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiJsonSerializerContext.cs new file mode 100644 index 0000000..dddc436 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiJsonSerializerContext.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using LyricFlow.Backend.Api; +using LyricFlow.Core.Dtos; + +namespace LyricFlow.Backend.Api; + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(AnalyzeRequest))] +[JsonSerializable(typeof(CandidateDto))] +[JsonSerializable(typeof(CapabilitiesDto))] +[JsonSerializable(typeof(CountDto))] +[JsonSerializable(typeof(FileCreateRequest))] +[JsonSerializable(typeof(FileDeleteRequest))] +[JsonSerializable(typeof(FileReadRequest))] +[JsonSerializable(typeof(FileReadResultDto))] +[JsonSerializable(typeof(FileRenameRequest))] +[JsonSerializable(typeof(FileWriteRequest))] +[JsonSerializable(typeof(FileWriteResultDto))] +[JsonSerializable(typeof(HealthDto))] +[JsonSerializable(typeof(KnownWordDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ProjectReadRequest))] +[JsonSerializable(typeof(ProjectStateDto))] +[JsonSerializable(typeof(ProjectWriteRequest))] +[JsonSerializable(typeof(RhymeGroupDto))] +[JsonSerializable(typeof(SaveScratchpadRequest))] +[JsonSerializable(typeof(SaveSnapshotRequest))] +[JsonSerializable(typeof(ScratchpadContentDto))] +[JsonSerializable(typeof(SessionClearRequest))] +[JsonSerializable(typeof(SessionLoadRequest))] +[JsonSerializable(typeof(SessionSaveRequest))] +[JsonSerializable(typeof(SessionTabSnapshotDto))] +[JsonSerializable(typeof(SnapshotDto))] +[JsonSerializable(typeof(SpellingIssueDto))] +[JsonSerializable(typeof(SuccessDto))] +[JsonSerializable(typeof(SuggestionResponseDto))] +[JsonSerializable(typeof(SynonymResponseDto))] +[JsonSerializable(typeof(AppCorePreferencesDto))] +internal partial class ApiJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/LyricFlow.Backend.Api.csproj b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/LyricFlow.Backend.Api.csproj new file mode 100644 index 0000000..2264c3a --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/LyricFlow.Backend.Api.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Program.cs b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Program.cs new file mode 100644 index 0000000..482d9c5 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Program.cs @@ -0,0 +1,294 @@ +using System.Text.RegularExpressions; +using LyricFlow.Backend.Api; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Engine; +using LyricFlow.Core.Services; +using LyricFlow.Core.Storage; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.Json; + +// MARK: - Application Bootstrap +#region Application Bootstrap + +var builder = WebApplication.CreateSlimBuilder(args); +builder.WebHost.UseUrls(builder.Configuration["Backend:Urls"] ?? "http://127.0.0.1:5000"); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonSerializerContext.Default); +}); + +var dbPath = builder.Configuration["Database:Path"] ?? "data/lyricflow.db"; +builder.Services.AddSingleton(new StorageService(dbPath)); + +var cmuDictPath = NltkPathResolver.ResolveCmudictPath(builder.Configuration["Nltk:CmudictPath"]); +var wordNetPath = NltkPathResolver.ResolveWordNetPath(builder.Configuration["Nltk:WordNetPath"]); +var processor = new PhoneticProcessor(cmuDictPath); +builder.Services.AddSingleton(processor); +builder.Services.AddSingleton(new WordNetLexicon(wordNetPath)); +builder.Services.AddSingleton(sp => new RhymeEngine(processor, sp.GetRequiredService())); +builder.Services.AddSingleton(sp => new SpellcheckEngine(processor, sp.GetRequiredService())); +builder.Services.AddSingleton(new ProjectStateService()); +builder.Services.AddSingleton(new SessionStoreService(builder.Configuration["State:SessionPath"])); +builder.Services.AddSingleton(new CorePreferencesStore(builder.Configuration["State:CorePreferencesPath"])); +builder.Services.AddSingleton(new FileService()); + +var app = builder.Build(); + +#endregion + +// MARK: - Health And Analysis Routes +#region Health And Analysis Routes + +app.MapGet("/api/health", (PhoneticProcessor phonetics, WordNetLexicon wordNet) => +{ + var capabilities = new CapabilitiesDto( + Analysis: true, + Phonetics: true, + Spellcheck: true, + Synonyms: wordNet.IsAvailable, + Autocorrect: true, + Projects: true, + Sessions: true, + Settings: true, + Files: true, + History: true, + Scratchpad: true, + HasCmudict: phonetics.Dictionary.Count > 0, + HasWordNet: wordNet.IsAvailable + ); + + return Results.Ok(new HealthDto( + Status: "healthy", + Version: "2.0.0", + Ready: phonetics.Dictionary.Count > 0, + DictionaryCount: phonetics.Dictionary.Count, + Capabilities: capabilities + )); +}); + +app.MapGet("/api/analysis/phonemes", (PhoneticProcessor phonetics, [FromQuery] string word) => +{ + return Results.Ok(phonetics.GetPhonemes(word)); +}); + +app.MapGet("/api/analysis/syllables", (RhymeEngine engine, [FromQuery] string word) => +{ + return Results.Ok(new CountDto(engine.CountSyllables(word))); +}); + +app.MapGet("/api/analysis/suggestions", (RhymeEngine engine, [FromQuery] string word, [FromQuery] int? limit) => +{ + return Results.Ok(engine.FindSuggestions(word, limit ?? 20)); +}); + +app.MapGet("/api/analysis/synonyms", (RhymeEngine engine, [FromQuery] string word, [FromQuery] int? limit) => +{ + return Results.Ok(engine.FindSynonyms(word, limit ?? 15)); +}); + +app.MapPost("/api/analysis/rhyme-groups", (RhymeEngine engine, [FromBody] AnalyzeRequest request) => +{ + return Results.Ok(engine.GetRhymeGroups(request.Text)); +}); + +app.MapPost("/api/analysis/line-densities", (RhymeEngine engine, [FromBody] AnalyzeRequest request) => +{ + return Results.Ok(CalculateLineDensities(engine, request.Text)); +}); + +app.MapGet("/api/engine/rhymes", (RhymeEngine engine, [FromQuery] string word, [FromQuery] int? limit) => +{ + return Results.Ok(engine.FindSuggestions(word, limit ?? 20)); +}); + +app.MapGet("/api/engine/syllables", (RhymeEngine engine, [FromQuery] string word) => +{ + return Results.Ok(new CountDto(engine.CountSyllables(word))); +}); + +app.MapPost("/api/engine/analyze", (RhymeEngine engine, [FromBody] AnalyzeRequest request) => +{ + return Results.Ok(engine.GetRhymeGroups(request.Text)); +}); + +app.MapPost("/api/engine/densities", (RhymeEngine engine, [FromBody] AnalyzeRequest request) => +{ + return Results.Ok(CalculateLineDensities(engine, request.Text)); +}); + +#endregion + +// MARK: - Spellcheck And State Routes +#region Spellcheck And State Routes + +app.MapGet("/api/spellcheck/known", (SpellcheckEngine engine, [FromQuery] string word) => +{ + return Results.Ok(new KnownWordDto(engine.IsKnownWord(word))); +}); + +app.MapGet("/api/spellcheck/suggestions", (SpellcheckEngine engine, [FromQuery] string word, [FromQuery] int? limit) => +{ + return Results.Ok(engine.GetSpellingSuggestions(word, limit ?? 6)); +}); + +app.MapGet("/api/spellcheck/autocorrect", (SpellcheckEngine engine, [FromQuery] string word) => +{ + return Results.Ok(new CandidateDto(engine.GetAutocorrectCandidate(word))); +}); + +app.MapPost("/api/spellcheck/issues", (SpellcheckEngine engine, [FromBody] AnalyzeRequest request, [FromQuery] int? limit) => +{ + return Results.Ok(engine.GetTextSpellingIssues(request.Text, limit ?? 6)); +}); + +app.MapPost("/api/projects/read", (ProjectStateService projects, [FromBody] ProjectReadRequest request) => +{ + return Results.Ok(projects.ReadProject(request.ProjectFile)); +}); + +app.MapPost("/api/projects/write", (ProjectStateService projects, [FromBody] ProjectWriteRequest request) => +{ + projects.WriteProject(request.ProjectFile, request.State); + return Results.Ok(new SuccessDto(true)); +}); + +app.MapPost("/api/session/load", (SessionStoreService sessionStore, [FromBody] SessionLoadRequest request) => +{ + return Results.Ok(sessionStore.Load(request.WorkspaceRoot)); +}); + +app.MapPost("/api/session/save", (SessionStoreService sessionStore, [FromBody] SessionSaveRequest request) => +{ + sessionStore.Save(request.WorkspaceRoot, request.Snapshots); + return Results.Ok(new SuccessDto(true)); +}); + +app.MapPost("/api/session/clear", (SessionStoreService sessionStore, [FromBody] SessionClearRequest request) => +{ + sessionStore.Clear(request.WorkspaceRoot); + return Results.Ok(new SuccessDto(true)); +}); + +app.MapGet("/api/settings", (CorePreferencesStore settingsStore) => +{ + return Results.Ok(settingsStore.Load()); +}); + +app.MapPost("/api/settings", (CorePreferencesStore settingsStore, [FromBody] AppCorePreferencesDto preferences) => +{ + settingsStore.Save(preferences); + return Results.Ok(new SuccessDto(true)); +}); + +app.MapPost("/api/files/read", (FileService files, [FromBody] FileReadRequest request) => +{ + return Results.Ok(files.ReadFile(request.Path)); +}); + +app.MapPost("/api/files/write", (FileService files, [FromBody] FileWriteRequest request) => +{ + return Results.Ok(files.WriteFile(request.Path, request.Content)); +}); + +app.MapPost("/api/files/create", (FileService files, [FromBody] FileCreateRequest request) => +{ + return Results.Ok(request.IsDirectory ? files.CreateDirectory(request.Path) : files.CreateFile(request.Path)); +}); + +app.MapPost("/api/files/rename", (FileService files, [FromBody] FileRenameRequest request) => +{ + return Results.Ok(files.Rename(request.OldPath, request.NewPath, request.RootPath)); +}); + +app.MapPost("/api/files/delete", (FileService files, [FromBody] FileDeleteRequest request) => +{ + return Results.Ok(files.Delete(request.Path, request.RootPath)); +}); + +#endregion + +// MARK: - Storage Routes +#region Storage Routes + +app.MapPost("/api/history/snapshots", async (StorageService storage, [FromBody] SaveSnapshotRequest request) => +{ + await storage.SaveSnapshotAsync(request.FilePath, request.Content); + return Results.Ok(); +}); + +app.MapGet("/api/history/snapshots", async (StorageService storage, [FromQuery] string filePath) => +{ + var snapshots = await storage.GetSnapshotsAsync(filePath); + return Results.Ok(snapshots.Select(snapshot => snapshot.ToDto()).ToList()); +}); + +app.MapPost("/api/snapshots", async (StorageService storage, [FromBody] SaveSnapshotRequest request) => +{ + await storage.SaveSnapshotAsync(request.FilePath, request.Content); + return Results.Ok(); +}); + +app.MapGet("/api/snapshots", async (StorageService storage, [FromQuery] string filePath) => +{ + var snapshots = await storage.GetSnapshotsAsync(filePath); + return Results.Ok(snapshots.Select(snapshot => snapshot.ToDto()).ToList()); +}); + +app.MapPost("/api/scratchpad", async (StorageService storage, [FromBody] SaveScratchpadRequest request) => +{ + await storage.SaveScratchpadAsync(request.ProjectId, request.Content); + return Results.Ok(); +}); + +app.MapGet("/api/scratchpad/{projectId}", async (StorageService storage, string projectId) => +{ + return Results.Ok(new ScratchpadContentDto(await storage.GetScratchpadAsync(projectId))); +}); + +app.Run(); + +#endregion + +// MARK: - Route Utilities +#region Route Utilities + +static List CalculateLineDensities(RhymeEngine engine, string text) +{ + var lines = text.Split('\n'); + var groups = engine.GetRhymeGroups(text); + using var groupIter = groups.GetEnumerator(); + + var densities = new List(); + foreach (var line in lines) + { + var stripped = line.Trim(); + if (stripped.StartsWith("#") || stripped.StartsWith("@") || stripped.StartsWith(">")) + { + densities.Add(0.0); + continue; + } + + var analysisText = Regex.Replace(line, @"\[.*?\]", ""); + var words = Regex.Matches(analysisText, @"\b\w+\b"); + if (words.Count == 0) + { + densities.Add(0.0); + continue; + } + + var rhymeCount = 0; + for (var index = 0; index < words.Count; index++) + { + if (groupIter.MoveNext() && groupIter.Current.Group is not null) + { + rhymeCount++; + } + } + + densities.Add((double)rhymeCount / words.Count); + } + + return densities; +} + +#endregion diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Properties/launchSettings.json b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Properties/launchSettings.json new file mode 100644 index 0000000..69a9251 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5195", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7081;http://localhost:5195", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.Development.json b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.json b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.json new file mode 100644 index 0000000..8b2418e --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/appsettings.json @@ -0,0 +1,23 @@ +{ + "Backend": { + "Urls": "http://127.0.0.1:5000" + }, + "Database": { + "Path": "data/lyricflow.db" + }, + "Nltk": { + "CmudictPath": "", + "WordNetPath": "" + }, + "State": { + "SessionPath": "", + "CorePreferencesPath": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Backend.Api/data/lyricflow.db b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/data/lyricflow.db new file mode 100644 index 0000000..53c038d Binary files /dev/null and b/LyricFlow.Core.Backend/LyricFlow.Backend.Api/data/lyricflow.db differ diff --git a/LyricFlow.Core.Backend/LyricFlow.Core.slnx b/LyricFlow.Core.Backend/LyricFlow.Core.slnx new file mode 100644 index 0000000..ba788ff --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core.slnx @@ -0,0 +1,2 @@ + + diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Class1.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Class1.cs new file mode 100644 index 0000000..a3968da --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Class1.cs @@ -0,0 +1,6 @@ +namespace LyricFlow.Core; + +public class Class1 +{ + +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Dtos/CoreDtos.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Dtos/CoreDtos.cs new file mode 100644 index 0000000..73a431e --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Dtos/CoreDtos.cs @@ -0,0 +1,117 @@ +using System.Text.Json.Serialization; + +namespace LyricFlow.Core.Dtos; + +public record CapabilitiesDto( + [property: JsonPropertyName("analysis")] bool Analysis, + [property: JsonPropertyName("phonetics")] bool Phonetics, + [property: JsonPropertyName("spellcheck")] bool Spellcheck, + [property: JsonPropertyName("synonyms")] bool Synonyms, + [property: JsonPropertyName("autocorrect")] bool Autocorrect, + [property: JsonPropertyName("projects")] bool Projects, + [property: JsonPropertyName("sessions")] bool Sessions, + [property: JsonPropertyName("settings")] bool Settings, + [property: JsonPropertyName("files")] bool Files, + [property: JsonPropertyName("history")] bool History, + [property: JsonPropertyName("scratchpad")] bool Scratchpad, + [property: JsonPropertyName("has_cmudict")] bool HasCmudict, + [property: JsonPropertyName("has_wordnet")] bool HasWordNet +); + +public record HealthDto( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("ready")] bool Ready, + [property: JsonPropertyName("dictionary_count")] int DictionaryCount, + [property: JsonPropertyName("capabilities")] CapabilitiesDto Capabilities +); + +public record ProjectStateDto( + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("open_files")] List OpenFiles, + [property: JsonPropertyName("active_file")] string? ActiveFile, + [property: JsonPropertyName("cursor_positions")] Dictionary CursorPositions, + [property: JsonPropertyName("scratchpad_open")] bool ScratchpadOpen +); + +public record SessionTabSnapshotDto( + [property: JsonPropertyName("tab_id")] string TabId, + [property: JsonPropertyName("file_path")] string? FilePath, + [property: JsonPropertyName("display_name")] string DisplayName, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("cursor_position")] int CursorPosition, + [property: JsonPropertyName("is_dirty")] bool IsDirty, + [property: JsonPropertyName("is_untitled")] bool IsUntitled, + [property: JsonPropertyName("snapshot_mtime")] double? SnapshotMtime, + [property: JsonPropertyName("workspace_root")] string? WorkspaceRoot, + [property: JsonPropertyName("updated_at")] string UpdatedAt +); + +public record AppCorePreferencesDto( + [property: JsonPropertyName("reopen_last_project")] bool ReopenLastProject, + [property: JsonPropertyName("restore_unsaved_tabs")] bool RestoreUnsavedTabs, + [property: JsonPropertyName("last_project_file")] string LastProjectFile +); + +public record FileReadResultDto( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("content")] string? Content, + [property: JsonPropertyName("path")] string? Path +); + +public record FileWriteResultDto( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("path")] string? Path +); + +public record SnapshotDto( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("file_path")] string FilePath, + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("timestamp")] double Timestamp +); + +public record CountDto( + [property: JsonPropertyName("count")] int Count +); + +public record KnownWordDto( + [property: JsonPropertyName("is_known")] bool IsKnown +); + +public record CandidateDto( + [property: JsonPropertyName("candidate")] string? Candidate +); + +public record SuccessDto( + [property: JsonPropertyName("success")] bool Success +); + +public record ScratchpadContentDto( + [property: JsonPropertyName("content")] string Content +); + +public record RhymeGroupDto( + [property: JsonPropertyName("word")] string Word, + [property: JsonPropertyName("group")] int? Group +); + +public record SuggestionResponseDto( + [property: JsonPropertyName("perfect")] List Perfect, + [property: JsonPropertyName("slant")] List Slant +); + +public record SynonymResponseDto( + [property: JsonPropertyName("synonyms")] List Synonyms, + [property: JsonPropertyName("vibe")] List Vibe +); + +public record SpellingIssueDto( + [property: JsonPropertyName("word")] string Word, + [property: JsonPropertyName("normalized")] string Normalized, + [property: JsonPropertyName("line")] int Line, + [property: JsonPropertyName("suggestions")] List Suggestions +); diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Engine/PhoneticProcessor.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/PhoneticProcessor.cs new file mode 100644 index 0000000..38966cc --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/PhoneticProcessor.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; +using System.IO; +using System.Collections.Generic; +using System.Linq; + +namespace LyricFlow.Core.Engine; + +public class PhoneticProcessor +{ + private readonly Dictionary>> _dictionary = new(); + private static readonly Regex WordCleanupRegex = new(@"[^a-z']", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public PhoneticProcessor(string? cmudictPath) + { + if (!string.IsNullOrWhiteSpace(cmudictPath)) + { + LoadCmuDict(cmudictPath); + } + } + + private void LoadCmuDict(string path) + { + if (!File.Exists(path)) return; + + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith(";;;")) continue; + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3) continue; + + var word = parts[0].ToLower(); + // The format from NLTK has [WORD] [VARIATION_ID] [PH1] [PH2]... + var phonemes = parts.Skip(2).ToList(); + + if (!_dictionary.TryGetValue(word, out var variations)) + { + variations = new List>(); + _dictionary[word] = variations; + } + variations.Add(phonemes); + } + } + + public string NormalizeWord(string word) + { + word = word.ToLower().Trim(); + word = WordCleanupRegex.Replace(word, ""); + + if (word.EndsWith("in'")) + { + word = word[..^1] + "g"; + } + + return word; + } + + public List> GetPhonemes(string word) + { + var normalized = NormalizeWord(word); + if (_dictionary.TryGetValue(normalized, out var phones)) + { + return phones; + } + return new List>(); + } + + public Dictionary>> Dictionary => _dictionary; +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Engine/RhymeEngine.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/RhymeEngine.cs new file mode 100644 index 0000000..c4683e2 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/RhymeEngine.cs @@ -0,0 +1,281 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using System.Text.RegularExpressions; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Services; + +namespace LyricFlow.Core.Engine; + +public class RhymeEngine +{ + private readonly PhoneticProcessor _processor; + private readonly WordNetLexicon _wordNet; + private readonly Dictionary> _perfectIndex = new(); + private readonly Dictionary> _slantIndex = new(); + private bool _isIndexed = false; + + public RhymeEngine(PhoneticProcessor processor, WordNetLexicon wordNet) + { + _processor = processor; + _wordNet = wordNet; + } + + public int CountSyllables(string word) + { + var phones = _processor.GetPhonemes(word); + if (phones.Count > 0) + { + return phones[0].Count(p => p.Any(char.IsDigit)); + } + + // Fallback rule-based syllable count + word = word.ToLower(); + int count = 0; + string vowels = "aeiouy"; + if (string.IsNullOrEmpty(word)) return 0; + if (vowels.Contains(word[0])) count++; + for (int i = 1; i < word.Length; i++) + { + if (vowels.Contains(word[i]) && !vowels.Contains(word[i - 1])) count++; + } + if (word.EndsWith("e")) count--; + return Math.Max(1, count); + } + + public void EnsureIndexed() + { + if (_isIndexed) return; + + foreach (var kvp in _processor.Dictionary) + { + var word = kvp.Key; + foreach (var phones in kvp.Value) + { + if (phones.Count == 0) continue; + + // Perfect index (last 2 phonemes) + var suffix = string.Join(" ", phones.Skip(Math.Max(0, phones.Count - 2))); + if (!_perfectIndex.ContainsKey(suffix)) _perfectIndex[suffix] = new HashSet(); + _perfectIndex[suffix].Add(word); + + // Slant index (last stressed vowel) + for (int i = phones.Count - 1; i >= 0; i--) + { + if (phones[i].Any(char.IsDigit)) + { + var vowel = phones[i]; + if (!_slantIndex.ContainsKey(vowel)) _slantIndex[vowel] = new HashSet(); + _slantIndex[vowel].Add(word); + break; + } + } + } + } + _isIndexed = true; + } + + // MARK: - Calculation Logic + #region Calculation Logic + + public double CalculateSimilarity(string word1, string word2) + { + var phones1 = _processor.GetPhonemes(word1); + var phones2 = _processor.GetPhonemes(word2); + if (phones1.Count == 0 || phones2.Count == 0) return 0.0; + + double maxScore = 0.0; + foreach (var p1 in phones1) + { + foreach (var p2 in phones2) + { + maxScore = Math.Max(maxScore, PhonMatch(p1, p2)); + } + } + return maxScore; + } + + private double PhonMatch(List first, List second) + { + var fRange = first.AsEnumerable().Reverse().ToList(); + var sRange = second.AsEnumerable().Reverse().ToList(); + int limit = Math.Min(fRange.Count, sRange.Count); + int hits = 0; + int total = limit; + + for (int i = 0; i < limit; i++) + { + if (fRange[i] == sRange[i]) + { + hits++; + if (char.IsDigit(fRange[i].Last())) + { + hits++; + total++; + } + } + else + { + break; + } + } + + return total > 0 ? (double)hits / total : 0.0; + } + + #endregion + + // MARK: - Suggestion Queries + #region Suggestion Queries + + public SuggestionResponseDto FindSuggestions(string word, int limit = 20) + { + EnsureIndexed(); + var normalized = _processor.NormalizeWord(word); + var phonesList = _processor.GetPhonemes(normalized); + if (phonesList.Count == 0) return new SuggestionResponseDto([], []); + + var perfect = new HashSet(); + var slant = new HashSet(); + + foreach (var phones in phonesList) + { + var suffix = string.Join(" ", phones.Skip(Math.Max(0, phones.Count - 2))); + if (_perfectIndex.TryGetValue(suffix, out var pMatches)) + { + perfect.UnionWith(pMatches); + } + + for (int i = phones.Count - 1; i >= 0; i--) + { + if (phones[i].Any(char.IsDigit)) + { + if (_slantIndex.TryGetValue(phones[i], out var sMatches)) + { + slant.UnionWith(sMatches); + } + break; + } + } + } + + perfect.Remove(normalized); + slant.Remove(normalized); + slant.ExceptWith(perfect); + + return new SuggestionResponseDto( + perfect.OrderBy(x => x).Take(limit).ToList(), + slant.OrderBy(x => x).Take(limit).ToList() + ); + } + + public SynonymResponseDto FindSynonyms(string word, int limit = 15) + { + return _wordNet.FindSynonyms(word, limit); + } + + #endregion + + // MARK: - Rhyme Grouping + #region Rhyme Grouping + + public List GetWordSuffixes(string word) + { + return _processor.GetPhonemes(word) + .Select(phones => phones.Skip(Math.Max(0, phones.Count - 2)).ToArray()) + .ToList(); + } + + public List GetRhymeGroups(string text) + { + var lines = text.Split('\n'); + var flatWords = new List<(string Orig, string Clean, int Line)>(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (line.StartsWith("#") || line.StartsWith("@") || line.StartsWith(">")) continue; + + // Remove tags [tag] and find words + var analysisText = Regex.Replace(line, @"\[.*?\]", ""); + var words = Regex.Matches(analysisText, @"\b\w+\b"); + foreach (Match match in words) + { + var clean = _processor.NormalizeWord(match.Value); + if (!string.IsNullOrEmpty(clean)) + { + flatWords.Add((match.Value, clean, i)); + } + } + } + + if (flatWords.Count == 0) return []; + + var wordToGroup = new Dictionary(); + var nextGroupId = 0; + EnsureIndexed(); + for (int i = 0; i < flatWords.Count; i++) + { + var current = flatWords[i]; + var suffixes = GetWordSuffixes(current.Clean); + if (suffixes.Count == 0) continue; + + bool matchFound = false; + + for (int j = Math.Max(0, i - 20); j < i; j++) + { + var prev = flatWords[j]; + if (current.Line - prev.Line > 4) continue; + if (current.Clean == prev.Clean) continue; + + var prevSuffixes = GetWordSuffixes(prev.Clean); + if (prevSuffixes.Count == 0) continue; + + if (SuffixesOverlap(suffixes, prevSuffixes)) + { + if (wordToGroup.TryGetValue(prev.Clean, out var gid) && gid != null) + { + wordToGroup[current.Clean] = gid; + matchFound = true; + break; + } + } + } + + if (!matchFound) + { + for (int j = Math.Max(0, i - 20); j < i; j++) + { + var prev = flatWords[j]; + if (current.Line - prev.Line > 4) continue; + if (current.Clean == prev.Clean) continue; + + var prevSuffixes = GetWordSuffixes(prev.Clean); + if (SuffixesOverlap(suffixes, prevSuffixes)) + { + var gid = nextGroupId++; + wordToGroup[prev.Clean] = gid; + wordToGroup[current.Clean] = gid; + break; + } + } + } + } + + return flatWords.Select(w => new RhymeGroupDto(w.Clean, wordToGroup.GetValueOrDefault(w.Clean, null))).ToList(); + } + + private bool SuffixesOverlap(List first, List second) + { + foreach (var f in first) + { + foreach (var s in second) + { + if (f.SequenceEqual(s)) return true; + } + } + return false; + } + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Engine/SpellcheckEngine.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/SpellcheckEngine.cs new file mode 100644 index 0000000..225ecbe --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Engine/SpellcheckEngine.cs @@ -0,0 +1,455 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Services; + +namespace LyricFlow.Core.Engine; + +public class SpellcheckEngine +{ + private readonly PhoneticProcessor _processor; + private readonly WordNetLexicon _wordNet; + private Dictionary>? _cmuByInitial; + + // MARK: - Lifecycle + #region Lifecycle + + public SpellcheckEngine(PhoneticProcessor processor, WordNetLexicon wordNet) + { + _processor = processor; + _wordNet = wordNet; + } + + #endregion + + // MARK: - Dictionary Index + #region Dictionary Index + + private Dictionary> CmuWordsByInitial + { + get + { + if (_cmuByInitial == null) + { + _cmuByInitial = new Dictionary>(); + foreach (var word in _processor.Dictionary.Keys) + { + if (string.IsNullOrEmpty(word)) continue; + char initial = word[0]; + if (!_cmuByInitial.ContainsKey(initial)) _cmuByInitial[initial] = new List(); + _cmuByInitial[initial].Add(word); + } + } + return _cmuByInitial; + } + } + + #endregion + + // MARK: - Suggestion Queries + #region Suggestion Queries + + public bool IsKnownWord(string word) + { + var normalized = _processor.NormalizeWord(word); + if (string.IsNullOrEmpty(normalized)) return true; + return _processor.Dictionary.ContainsKey(normalized) || _wordNet.ContainsWord(normalized); + } + + public List GetSpellingSuggestions(string word, int limit = 6) + { + var normalized = _processor.NormalizeWord(word); + if (string.IsNullOrEmpty(normalized) || IsKnownWord(normalized)) return new List(); + + char initial = normalized[0]; + if (!CmuWordsByInitial.TryGetValue(initial, out var candidates)) + { + candidates = _processor.Dictionary.Keys.ToList(); + } + + var lengthFiltered = candidates.Where(w => Math.Abs(w.Length - normalized.Length) <= 3).ToList(); + if (lengthFiltered.Count == 0) lengthFiltered = candidates; + + return GetCloseMatches(normalized, lengthFiltered, limit, 0.75); + } + + private List GetCloseMatches(string word, List possibilities, int n, double cutoff) + { + var scored = new List<(int HeuristicRank, int Distance, double Similarity, int LengthDelta, int SharedPrefix, int SharedSuffix, string Match)>(); + foreach (var p in possibilities) + { + double ratio = CalculateSimilarityRatio(word, p); + if (ratio >= cutoff) + { + scored.Add(( + HeuristicRank(word, p), + DamerauLevenshteinDistance(word, p), + SequenceSimilarity(word, p), + Math.Abs(word.Length - p.Length), + SharedPrefixLength(word, p), + SharedSuffixLength(word, p), + p + )); + } + } + + return scored + .OrderBy(item => item.HeuristicRank) + .ThenBy(item => item.Distance) + .ThenByDescending(item => item.Similarity) + .ThenBy(item => item.LengthDelta) + .ThenByDescending(item => item.SharedPrefix) + .ThenByDescending(item => item.SharedSuffix) + .ThenBy(item => item.Match, StringComparer.Ordinal) + .Take(n) + .Select(item => item.Match) + .ToList(); + } + + private double CalculateSimilarityRatio(string a, string b) + { + int distance = DamerauLevenshteinDistance(a, b); + int totalLen = a.Length + b.Length; + if (totalLen == 0) return 1.0; + return (double)(totalLen - distance) / totalLen; + } + + #endregion + + // MARK: - Similarity Helpers + #region Similarity Helpers + + public static int DamerauLevenshteinDistance(string a, string b) + { + if (a == b) return 0; + if (string.IsNullOrEmpty(a)) return b.Length; + if (string.IsNullOrEmpty(b)) return a.Length; + + var da = new Dictionary(); + foreach (var ch in a.Concat(b)) + { + if (!da.ContainsKey(ch)) + { + da[ch] = 0; + } + } + + int maxDistance = a.Length + b.Length; + int[,] d = new int[a.Length + 2, b.Length + 2]; + d[0, 0] = maxDistance; + + for (int i = 0; i <= a.Length; i++) + { + d[i + 1, 0] = maxDistance; + d[i + 1, 1] = i; + } + + for (int j = 0; j <= b.Length; j++) + { + d[0, j + 1] = maxDistance; + d[1, j + 1] = j; + } + + for (int i = 1; i <= a.Length; i++) + { + int db = 0; + for (int j = 1; j <= b.Length; j++) + { + int i1 = da[b[j - 1]]; + int j1 = db; + int cost = 1; + if (a[i - 1] == b[j - 1]) + { + cost = 0; + db = j; + } + + d[i + 1, j + 1] = Math.Min( + Math.Min( + d[i, j] + cost, + d[i + 1, j] + 1 + ), + Math.Min( + d[i, j + 1] + 1, + d[i1, j1] + (i - i1 - 1) + 1 + (j - j1 - 1) + ) + ); + } + + da[a[i - 1]] = i; + } + + return d[a.Length + 1, b.Length + 1]; + } + + public static int LevenshteinDistance(string a, string b) + { + if (a == b) return 0; + if (string.IsNullOrEmpty(a)) return b.Length; + if (string.IsNullOrEmpty(b)) return a.Length; + + int[] prevRow = new int[b.Length + 1]; + for (int i = 0; i <= b.Length; i++) prevRow[i] = i; + + for (int i = 1; i <= a.Length; i++) + { + int[] row = new int[b.Length + 1]; + row[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int insertCost = row[j - 1] + 1; + int deleteCost = prevRow[j] + 1; + int replaceCost = prevRow[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1); + row[j] = Math.Min(Math.Min(insertCost, deleteCost), replaceCost); + } + prevRow = row; + } + return prevRow[b.Length]; + } + + private static int SharedPrefixLength(string a, string b) + { + int limit = Math.Min(a.Length, b.Length); + int count = 0; + while (count < limit && a[count] == b[count]) + { + count++; + } + + return count; + } + + private static int SharedSuffixLength(string a, string b) + { + int count = 0; + while ( + count < a.Length && + count < b.Length && + a[a.Length - 1 - count] == b[b.Length - 1 - count] + ) + { + count++; + } + + return count; + } + + private static double SequenceSimilarity(string a, string b) + { + int lcs = LongestCommonSubsequenceLength(a, b); + int total = a.Length + b.Length; + if (total == 0) + { + return 1.0; + } + + return (2.0 * lcs) / total; + } + + private static int LongestCommonSubsequenceLength(string a, string b) + { + int[,] dp = new int[a.Length + 1, b.Length + 1]; + for (int i = 1; i <= a.Length; i++) + { + for (int j = 1; j <= b.Length; j++) + { + if (a[i - 1] == b[j - 1]) + { + dp[i, j] = dp[i - 1, j - 1] + 1; + } + else + { + dp[i, j] = Math.Max(dp[i - 1, j], dp[i, j - 1]); + } + } + } + + return dp[a.Length, b.Length]; + } + + private static int HeuristicRank(string source, string candidate) + { + if (IsAdjacentTransposition(source, candidate)) + { + return 0; + } + + if (IsRepeatedLetterExpansion(source, candidate)) + { + return 1; + } + + return 2; + } + + private static bool IsAdjacentTransposition(string source, string candidate) + { + if (source.Length != candidate.Length) + { + return false; + } + + for (int i = 0; i < source.Length - 1; i++) + { + if (source[i] == candidate[i]) + { + continue; + } + + return source[i] == candidate[i + 1] + && source[i + 1] == candidate[i] + && source[(i + 2)..] == candidate[(i + 2)..] + && source[..i] == candidate[..i]; + } + + return false; + } + + private static bool IsRepeatedLetterExpansion(string source, string candidate) + { + if (candidate.Length != source.Length + 1) + { + return false; + } + + for (int i = 0; i < candidate.Length - 1; i++) + { + if (candidate[i] != candidate[i + 1]) + { + continue; + } + + var collapsed = candidate.Remove(i, 1); + if (collapsed == source) + { + return true; + } + } + + return false; + } + + #endregion + + // MARK: - Autocorrect + #region Autocorrect + + public string? GetAutocorrectCandidate(string word, double minRatio = 0.75, int maxEditDistance = 2) + { + var normalized = _processor.NormalizeWord(word); + if (string.IsNullOrEmpty(normalized) || normalized.Length < 3 || IsKnownWord(normalized)) + { + return null; + } + + var suggestions = GetSpellingSuggestions(normalized, 3); + if (suggestions.Count == 0) + { + return null; + } + + var scored = new List<(double Ratio, int LexicalRank, int ApostrophePenalty, int LengthDelta, int Distance, string Word)>(); + foreach (var candidate in suggestions) + { + var ratio = CalculateSimilarityRatio(normalized, candidate); + var distance = DamerauLevenshteinDistance(normalized, candidate); + if (ratio < minRatio || distance > maxEditDistance) + { + continue; + } + + scored.Add(( + ratio, + _wordNet.ContainsWord(candidate) ? 1 : 0, + candidate.Contains('\'') ? 1 : 0, + Math.Abs(candidate.Length - normalized.Length), + distance, + candidate + )); + } + + var ranked = scored + .OrderByDescending(item => item.LexicalRank) + .ThenBy(item => item.ApostrophePenalty) + .ThenBy(item => item.LengthDelta) + .ThenBy(item => item.Distance) + .ThenByDescending(item => item.Ratio) + .ToList(); + + if (ranked.Count == 0) + { + return null; + } + + var best = ranked[0]; + if (normalized.EndsWith("ign", StringComparison.Ordinal)) + { + var ingCandidate = ranked + .Where(item => item.Word.EndsWith("ing", StringComparison.Ordinal)) + .OrderByDescending(item => item.LexicalRank) + .ThenBy(item => item.ApostrophePenalty) + .ThenBy(item => item.LengthDelta) + .ThenBy(item => item.Distance) + .ThenByDescending(item => item.Ratio) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(ingCandidate.Word)) + { + return ingCandidate.Word; + } + } + + var exactLength = ranked + .Where(item => item.LengthDelta == 0) + .OrderByDescending(item => item.LexicalRank) + .ThenBy(item => item.ApostrophePenalty) + .ThenBy(item => item.Distance) + .ThenByDescending(item => item.Ratio) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(exactLength.Word)) + { + return exactLength.Word; + } + + return best.Word; + } + + #endregion + + // MARK: - Text Analysis + #region Text Analysis + + public List GetTextSpellingIssues(string text, int suggestionLimit = 6) + { + var issues = new List(); + var lines = text.Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (line.StartsWith("#") || line.StartsWith("@") || line.StartsWith(">")) continue; + + // Remove tags [tag] + var analysisText = Regex.Replace(line, @"\[.*?\]", ""); + var words = Regex.Matches(analysisText, @"\b\w+\b"); + + foreach (Match match in words) + { + var rawWord = match.Value; + var normalized = _processor.NormalizeWord(rawWord); + if (string.IsNullOrEmpty(normalized)) continue; + if (IsKnownWord(normalized)) continue; + + issues.Add(new SpellingIssueDto( + rawWord, + normalized, + i, + GetSpellingSuggestions(normalized, suggestionLimit) + )); + } + } + return issues; + } + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/LyricFlow.Core.csproj b/LyricFlow.Core.Backend/LyricFlow.Core/LyricFlow.Core.csproj new file mode 100644 index 0000000..105d456 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/LyricFlow.Core.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + + + + + + diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Serialization/LyricFlowCoreJsonContext.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Serialization/LyricFlowCoreJsonContext.cs new file mode 100644 index 0000000..3f70767 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Serialization/LyricFlowCoreJsonContext.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Services; + +namespace LyricFlow.Core.Serialization; + +[JsonSourceGenerationOptions( + PropertyNameCaseInsensitive = true, + WriteIndented = true +)] +[JsonSerializable(typeof(AppCorePreferencesDto))] +[JsonSerializable(typeof(ProjectStateDto))] +[JsonSerializable(typeof(SessionPayload))] +internal partial class LyricFlowCoreJsonContext : JsonSerializerContext +{ +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/AppPaths.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/AppPaths.cs new file mode 100644 index 0000000..2a6c3f9 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/AppPaths.cs @@ -0,0 +1,24 @@ +namespace LyricFlow.Core.Services; + +public static class AppPaths +{ + public static string AppDataDirectory() + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrWhiteSpace(appData)) + { + appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".lyricflow"); + } + + var path = Path.Combine(appData, "LyricFlow"); + Directory.CreateDirectory(path); + return path; + } + + public static string ExplorerTrashDirectory() + { + var path = Path.Combine(AppDataDirectory(), "explorer_trash"); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/CorePreferencesStore.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/CorePreferencesStore.cs new file mode 100644 index 0000000..ded4433 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/CorePreferencesStore.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Serialization; + +namespace LyricFlow.Core.Services; + +public class CorePreferencesStore +{ + private readonly string _storagePath; + + public CorePreferencesStore(string? storagePath = null) + { + _storagePath = storagePath ?? Path.Combine(AppPaths.AppDataDirectory(), "core_preferences.json"); + var directory = Path.GetDirectoryName(_storagePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + } + + public AppCorePreferencesDto Load() + { + if (!File.Exists(_storagePath)) + { + return Default(); + } + + try + { + var prefs = JsonSerializer.Deserialize(File.ReadAllText(_storagePath), LyricFlowCoreJsonContext.Default.AppCorePreferencesDto); + return prefs ?? Default(); + } + catch (IOException) + { + return Default(); + } + catch (JsonException) + { + return Default(); + } + } + + public void Save(AppCorePreferencesDto prefs) + { + File.WriteAllText(_storagePath, JsonSerializer.Serialize(prefs, LyricFlowCoreJsonContext.Default.AppCorePreferencesDto)); + } + + private static AppCorePreferencesDto Default() + { + return new AppCorePreferencesDto(true, true, string.Empty); + } +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/FileService.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/FileService.cs new file mode 100644 index 0000000..0c67754 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/FileService.cs @@ -0,0 +1,193 @@ +using LyricFlow.Core.Dtos; + +namespace LyricFlow.Core.Services; + +public class FileService +{ + // MARK: - File Read And Write + #region File Read And Write + + public FileReadResultDto ReadFile(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + return new FileReadResultDto(true, $"Loaded {Path.GetFileName(fullPath)}", File.ReadAllText(fullPath), fullPath); + } + catch (Exception ex) + { + return new FileReadResultDto(false, ex.Message, null, null); + } + } + + public FileWriteResultDto WriteFile(string path, string content) + { + try + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, content); + return new FileWriteResultDto(true, $"Saved to {Path.GetFileName(fullPath)}", fullPath); + } + catch (Exception ex) + { + return new FileWriteResultDto(false, ex.Message, null); + } + } + + #endregion + + // MARK: - File System Mutations + #region File System Mutations + + public FileWriteResultDto CreateFile(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + using (File.Create(fullPath)) + { + } + + return new FileWriteResultDto(true, $"Created {Path.GetFileName(fullPath)}", fullPath); + } + catch (Exception ex) + { + return new FileWriteResultDto(false, ex.Message, null); + } + } + + public FileWriteResultDto CreateDirectory(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(fullPath); + return new FileWriteResultDto(true, $"Created {Path.GetFileName(fullPath)}", fullPath); + } + catch (Exception ex) + { + return new FileWriteResultDto(false, ex.Message, null); + } + } + + public FileWriteResultDto Rename(string oldPath, string newPath, string? rootPath) + { + try + { + var oldFullPath = Path.GetFullPath(oldPath); + var newFullPath = Path.GetFullPath(newPath); + if (!IsWithinRoot(rootPath, oldFullPath) || !IsWithinRoot(rootPath, newFullPath)) + { + return new FileWriteResultDto(false, "Operation blocked because the target is outside the current project root.", null); + } + + if (Directory.Exists(oldFullPath)) + { + Directory.Move(oldFullPath, newFullPath); + } + else + { + File.Move(oldFullPath, newFullPath); + } + + return new FileWriteResultDto(true, $"Renamed to {Path.GetFileName(newFullPath)}", newFullPath); + } + catch (Exception ex) + { + return new FileWriteResultDto(false, ex.Message, null); + } + } + + public FileWriteResultDto Delete(string path, string? rootPath) + { + try + { + var fullPath = Path.GetFullPath(path); + if (!IsWithinRoot(rootPath, fullPath)) + { + return new FileWriteResultDto(false, "Operation blocked because the target is outside the current project root.", null); + } + + if (!string.IsNullOrWhiteSpace(rootPath) && Path.GetFullPath(rootPath) == fullPath) + { + return new FileWriteResultDto(false, "Deleting the project root is not allowed.", null); + } + + var target = TrashTarget(fullPath); + if (Directory.Exists(fullPath)) + { + Directory.Move(fullPath, target); + } + else + { + File.Move(fullPath, target); + } + + return new FileWriteResultDto(true, $"Moved {Path.GetFileName(fullPath)} to LyricFlow trash", target); + } + catch (Exception ex) + { + return new FileWriteResultDto(false, ex.Message, null); + } + } + + #endregion + + // MARK: - Workspace Safety + #region Workspace Safety + + public bool IsWithinRoot(string? rootPath, string candidatePath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + return true; + } + + try + { + var rootFullPath = Path.GetFullPath(rootPath); + var candidateFullPath = Path.GetFullPath(candidatePath); + var relativePath = Path.GetRelativePath(rootFullPath, candidateFullPath); + return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + } + catch + { + return false; + } + } + + #endregion + + // MARK: - Trash Helpers + #region Trash Helpers + + private static string TrashTarget(string path) + { + var trashDir = AppPaths.ExplorerTrashDirectory(); + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var name = Path.GetFileName(path); + var target = Path.Combine(trashDir, $"{timestamp}-{name}"); + var counter = 1; + while (File.Exists(target) || Directory.Exists(target)) + { + target = Path.Combine(trashDir, $"{timestamp}-{counter}-{name}"); + counter++; + } + + return target; + } + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/NltkPathResolver.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/NltkPathResolver.cs new file mode 100644 index 0000000..b849c56 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/NltkPathResolver.cs @@ -0,0 +1,147 @@ +namespace LyricFlow.Core.Services; + +public static class NltkPathResolver +{ + // MARK: - Public Resolution + #region Public Resolution + + public static string? ResolveCmudictPath(string? configuredPath = null) + { + return ResolveResourceFile(configuredPath, "LYRICFLOW_CMUDICT_PATH", ["cmudict", "cmudict"]); + } + + public static string? ResolveWordNetPath(string? configuredPath = null) + { + foreach (var candidate in EnumerateCandidates(configuredPath, "LYRICFLOW_WORDNET_PATH", ["wordnet"])) + { + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + foreach (var candidate in EnumerateZipCandidates(configuredPath, "LYRICFLOW_WORDNET_PATH")) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + #endregion + + // MARK: - Shared Resolution Helpers + #region Shared Resolution Helpers + + private static string? ResolveResourceFile(string? configuredPath, string envVar, string[] resourceSegments) + { + foreach (var candidate in EnumerateCandidates(configuredPath, envVar, resourceSegments)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + #endregion + + // MARK: - Candidate Enumeration + #region Candidate Enumeration + + private static IEnumerable EnumerateCandidates(string? configuredPath, string envVar, string[] resourceSegments) + { + var roots = new List(); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + roots.Add(configuredPath); + } + + var envPath = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrWhiteSpace(envPath)) + { + roots.Add(envPath); + } + + var nltkDataEnv = Environment.GetEnvironmentVariable("NLTK_DATA"); + if (!string.IsNullOrWhiteSpace(nltkDataEnv)) + { + roots.AddRange( + nltkDataEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(root => Path.Combine(root, "corpora", Path.Combine(resourceSegments))) + ); + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + { + roots.Add(Path.Combine(appData, "nltk_data", "corpora", Path.Combine(resourceSegments))); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + roots.Add(Path.Combine(home, "nltk_data", "corpora", Path.Combine(resourceSegments))); + } + + roots.Add(Path.Combine(AppContext.BaseDirectory, "nltk_data", "corpora", Path.Combine(resourceSegments))); + + return roots + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(Path.GetFullPath); + } + + #endregion + + // MARK: - Zip Candidates + #region Zip Candidates + + private static IEnumerable EnumerateZipCandidates(string? configuredPath, string envVar) + { + var roots = new List(); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + roots.Add(configuredPath); + } + + var envPath = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrWhiteSpace(envPath)) + { + roots.Add(envPath); + } + + var nltkDataEnv = Environment.GetEnvironmentVariable("NLTK_DATA"); + if (!string.IsNullOrWhiteSpace(nltkDataEnv)) + { + roots.AddRange( + nltkDataEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(root => Path.Combine(root, "corpora", "wordnet.zip")) + ); + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + { + roots.Add(Path.Combine(appData, "nltk_data", "corpora", "wordnet.zip")); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + roots.Add(Path.Combine(home, "nltk_data", "corpora", "wordnet.zip")); + } + + roots.Add(Path.Combine(AppContext.BaseDirectory, "nltk_data", "corpora", "wordnet.zip")); + + return roots + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(Path.GetFullPath); + } + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/ProjectStateService.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/ProjectStateService.cs new file mode 100644 index 0000000..20348c6 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/ProjectStateService.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Serialization; + +namespace LyricFlow.Core.Services; + +public class ProjectStateService +{ + // MARK: - Project Read And Write + #region Project Read And Write + + public ProjectStateDto ReadProject(string projectFile) + { + var fullPath = Path.GetFullPath(projectFile); + using var stream = File.OpenRead(fullPath); + using var document = JsonDocument.Parse(stream); + var fallbackName = Path.GetFileName(Path.GetDirectoryName(fullPath)) ?? string.Empty; + return FromRaw(document.RootElement, fallbackName); + } + + public void WriteProject(string projectFile, ProjectStateDto state) + { + var fullPath = Path.GetFullPath(projectFile); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(fullPath, JsonSerializer.Serialize(state, LyricFlowCoreJsonContext.Default.ProjectStateDto)); + } + + #endregion + + // MARK: - Payload Conversion + #region Payload Conversion + + private static ProjectStateDto FromRaw(JsonElement payload, string fallbackName) + { + if (payload.ValueKind != JsonValueKind.Object) + { + return new ProjectStateDto(2, fallbackName, [], null, [], false); + } + + var version = ReadInt(payload, "version", 2); + var name = ReadString(payload, "name") ?? fallbackName; + var openFiles = ReadStringList(payload, "open_files"); + var activeFile = ReadString(payload, "active_file"); + var scratchpadOpen = ReadBool(payload, "scratchpad_open", false); + var cursorPositions = ReadCursorPositions(payload); + + return new ProjectStateDto(Math.Max(1, version), name, openFiles, activeFile, cursorPositions, scratchpadOpen); + } + + #endregion + + // MARK: - JSON Readers + #region JSON Readers + + private static Dictionary ReadCursorPositions(JsonElement payload) + { + var cursorPositions = new Dictionary(); + if (!payload.TryGetProperty("cursor_positions", out var cursorElement) || cursorElement.ValueKind != JsonValueKind.Object) + { + return cursorPositions; + } + + foreach (var property in cursorElement.EnumerateObject()) + { + if (TryReadInt(property.Value, out var value)) + { + cursorPositions[property.Name] = Math.Max(0, value); + } + } + + return cursorPositions; + } + + private static List ReadStringList(JsonElement payload, string key) + { + var values = new List(); + if (!payload.TryGetProperty(key, out var element) || element.ValueKind != JsonValueKind.Array) + { + return values; + } + + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var text = item.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + values.Add(text); + } + } + } + + return values; + } + + private static string? ReadString(JsonElement payload, string key) + { + if (payload.TryGetProperty(key, out var element) && element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + + return null; + } + + private static bool ReadBool(JsonElement payload, string key, bool fallback) + { + if (!payload.TryGetProperty(key, out var element)) + { + return fallback; + } + + return element.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(element.GetString(), out var value) => value, + _ => fallback, + }; + } + + private static int ReadInt(JsonElement payload, string key, int fallback) + { + if (payload.TryGetProperty(key, out var element) && TryReadInt(element, out var value)) + { + return value; + } + + return fallback; + } + + private static bool TryReadInt(JsonElement element, out int value) + { + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) + { + return true; + } + + if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) + { + return true; + } + + value = 0; + return false; + } + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/SessionStoreService.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/SessionStoreService.cs new file mode 100644 index 0000000..78d9b42 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/SessionStoreService.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using LyricFlow.Core.Dtos; +using LyricFlow.Core.Serialization; + +namespace LyricFlow.Core.Services; + +public class SessionStoreService +{ + public const string GlobalWorkspaceKey = "__global__"; + + private readonly string _storagePath; + + // MARK: - Lifecycle + #region Lifecycle + + public SessionStoreService(string? storagePath = null) + { + _storagePath = storagePath ?? Path.Combine(AppPaths.AppDataDirectory(), "session_snapshots.json"); + var directory = Path.GetDirectoryName(_storagePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + } + + #endregion + + // MARK: - Public Operations + #region Public Operations + + public List Load(string? workspaceRoot) + { + var payload = ReadPayload(); + return payload.Workspaces.TryGetValue(WorkspaceKey(workspaceRoot), out var snapshots) ? snapshots : []; + } + + public void Save(string? workspaceRoot, List snapshots) + { + var payload = ReadPayload(); + var key = WorkspaceKey(workspaceRoot); + if (snapshots.Count == 0) + { + payload.Workspaces.Remove(key); + } + else + { + payload.Workspaces[key] = snapshots; + } + + WritePayload(payload); + } + + public void Clear(string? workspaceRoot = null) + { + if (workspaceRoot is null) + { + if (File.Exists(_storagePath)) + { + File.Delete(_storagePath); + } + + return; + } + + var payload = ReadPayload(); + payload.Workspaces.Remove(WorkspaceKey(workspaceRoot)); + if (payload.Workspaces.Count == 0) + { + if (File.Exists(_storagePath)) + { + File.Delete(_storagePath); + } + + return; + } + + WritePayload(payload); + } + + #endregion + + // MARK: - Payload Persistence + #region Payload Persistence + + private SessionPayload ReadPayload() + { + if (!File.Exists(_storagePath)) + { + return new SessionPayload(); + } + + try + { + var payload = JsonSerializer.Deserialize(File.ReadAllText(_storagePath), LyricFlowCoreJsonContext.Default.SessionPayload); + return payload ?? new SessionPayload(); + } + catch (IOException) + { + return new SessionPayload(); + } + catch (JsonException) + { + return new SessionPayload(); + } + } + + private void WritePayload(SessionPayload payload) + { + payload.Version = 1; + File.WriteAllText(_storagePath, JsonSerializer.Serialize(payload, LyricFlowCoreJsonContext.Default.SessionPayload)); + } + + #endregion + + // MARK: - Workspace Keys + #region Workspace Keys + + private static string WorkspaceKey(string? workspaceRoot) + { + if (string.IsNullOrWhiteSpace(workspaceRoot)) + { + return GlobalWorkspaceKey; + } + + var fullPath = Path.GetFullPath(workspaceRoot); + return OperatingSystem.IsWindows() ? fullPath.ToLowerInvariant() : fullPath; + } + + #endregion + + // MARK: - Payload Model + #region Payload Model + + #endregion +} + +internal sealed class SessionPayload +{ + public int Version { get; set; } = 1; + public Dictionary> Workspaces { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Services/WordNetLexicon.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Services/WordNetLexicon.cs new file mode 100644 index 0000000..479073f --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Services/WordNetLexicon.cs @@ -0,0 +1,298 @@ +using System.Globalization; +using System.IO.Compression; +using LyricFlow.Core.Dtos; + +namespace LyricFlow.Core.Services; + +public class WordNetLexicon +{ + private readonly string? _wordNetPath; + private readonly Dictionary> _index = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<(char Pos, long Offset), Synset> _synsets = new(); + private bool _loaded; + + // MARK: - Lifecycle + #region Lifecycle + + public WordNetLexicon(string? wordNetPath) + { + _wordNetPath = wordNetPath; + } + + public bool IsAvailable + { + get + { + EnsureLoaded(); + return _index.Count > 0; + } + } + + #endregion + + // MARK: - Public Queries + #region Public Queries + + public bool ContainsWord(string word) + { + EnsureLoaded(); + return _index.ContainsKey(NormalizeLemma(word)); + } + + public SynonymResponseDto FindSynonyms(string word, int limit = 15) + { + EnsureLoaded(); + var normalized = NormalizeLemma(word); + if (!_index.TryGetValue(normalized, out var refs)) + { + return new SynonymResponseDto([], []); + } + + var synonyms = new SortedSet(StringComparer.OrdinalIgnoreCase); + var vibe = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (var synsetRef in refs) + { + if (!TryGetSynset(synsetRef.Pos, synsetRef.Offset, out var synset)) + { + continue; + } + + foreach (var lemma in synset.Words) + { + var name = lemma.Replace('_', ' '); + if (!name.Equals(word, StringComparison.OrdinalIgnoreCase)) + { + synonyms.Add(name); + } + } + + foreach (var pointer in synset.Pointers.Where(pointer => pointer.Symbol.StartsWith("@", StringComparison.Ordinal))) + { + if (!TryGetSynset(pointer.TargetPos, pointer.TargetOffset, out var target)) + { + continue; + } + + foreach (var lemma in target.Words) + { + vibe.Add(lemma.Replace('_', ' ')); + } + } + } + + return new SynonymResponseDto(synonyms.Take(limit).ToList(), vibe.Take(limit).ToList()); + } + + #endregion + + // MARK: - Corpus Loading + #region Corpus Loading + + private void EnsureLoaded() + { + if (_loaded) + { + return; + } + + _loaded = true; + if (string.IsNullOrWhiteSpace(_wordNetPath)) + { + return; + } + + LoadIndexFile('n', "index.noun"); + LoadIndexFile('v', "index.verb"); + LoadIndexFile('a', "index.adj"); + LoadIndexFile('r', "index.adv"); + + LoadDataFile("data.noun"); + LoadDataFile("data.verb"); + LoadDataFile("data.adj"); + LoadDataFile("data.adv"); + } + + private void LoadIndexFile(char pos, string relativePath) + { + foreach (var line in ReadCorpusLines(relativePath)) + { + if (string.IsNullOrWhiteSpace(line) || char.IsWhiteSpace(line[0])) + { + continue; + } + + var tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 6 || !int.TryParse(tokens[2], out var synsetCount) || !int.TryParse(tokens[3], out var pointerCount)) + { + continue; + } + + var offsetStart = 6 + pointerCount; + if (tokens.Length < offsetStart + synsetCount) + { + continue; + } + + var lemma = NormalizeLemma(tokens[0]); + if (!_index.TryGetValue(lemma, out var refs)) + { + refs = []; + _index[lemma] = refs; + } + + for (var i = 0; i < synsetCount; i++) + { + if (long.TryParse(tokens[offsetStart + i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var offset)) + { + refs.Add(new SynsetRef(pos, offset)); + } + } + } + } + + private void LoadDataFile(string relativePath) + { + foreach (var line in ReadCorpusLines(relativePath)) + { + if (string.IsNullOrWhiteSpace(line) || char.IsWhiteSpace(line[0])) + { + continue; + } + + var data = line.Split('|', 2)[0].Trim(); + var tokens = data.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 5 || !long.TryParse(tokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var offset)) + { + continue; + } + + var synsetType = tokens[2][0]; + if (!int.TryParse(tokens[3], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var wordCount)) + { + continue; + } + + var index = 4; + var words = new List(wordCount); + for (var i = 0; i < wordCount && index + 1 < tokens.Length; i++) + { + words.Add(tokens[index].ToLowerInvariant()); + index += 2; + } + + if (index >= tokens.Length || !int.TryParse(tokens[index], out var pointerCount)) + { + continue; + } + + index++; + var pointers = new List(pointerCount); + for (var i = 0; i < pointerCount && index + 3 < tokens.Length; i++) + { + if (long.TryParse(tokens[index + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var targetOffset)) + { + pointers.Add(new SynsetPointer(tokens[index], targetOffset, tokens[index + 2][0])); + } + + index += 4; + } + + var synset = new Synset(words, pointers); + _synsets[(synsetType, offset)] = synset; + if (synsetType == 's') + { + _synsets[('a', offset)] = synset; + } + } + } + + #endregion + + // MARK: - Synset Resolution + #region Synset Resolution + + private bool TryGetSynset(char pos, long offset, out Synset synset) + { + if (_synsets.TryGetValue((pos, offset), out synset!)) + { + return true; + } + + if (pos == 'a' && _synsets.TryGetValue(('s', offset), out synset!)) + { + return true; + } + + synset = null!; + return false; + } + + #endregion + + // MARK: - Corpus Readers + #region Corpus Readers + + private IEnumerable ReadCorpusLines(string relativePath) + { + if (string.IsNullOrWhiteSpace(_wordNetPath)) + { + yield break; + } + + if (Directory.Exists(_wordNetPath)) + { + var path = Path.Combine(_wordNetPath, relativePath); + if (!File.Exists(path)) + { + yield break; + } + + foreach (var line in File.ReadLines(path)) + { + yield return line; + } + + yield break; + } + + if (!File.Exists(_wordNetPath) || !string.Equals(Path.GetExtension(_wordNetPath), ".zip", StringComparison.OrdinalIgnoreCase)) + { + yield break; + } + + using var archive = ZipFile.OpenRead(_wordNetPath); + var entry = archive.GetEntry($"wordnet/{relativePath}"); + if (entry is null) + { + yield break; + } + + using var stream = entry.Open(); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + if (line is not null) + { + yield return line; + } + } + } + + #endregion + + // MARK: - Normalization And Records + #region Normalization And Records + + private static string NormalizeLemma(string word) + { + return word.Trim().ToLowerInvariant().Replace(' ', '_'); + } + + private sealed record SynsetRef(char Pos, long Offset); + private sealed record Synset(List Words, List Pointers); + private sealed record SynsetPointer(string Symbol, long TargetOffset, char TargetPos); + + #endregion +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Storage/Snapshot.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Storage/Snapshot.cs new file mode 100644 index 0000000..132247e --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Storage/Snapshot.cs @@ -0,0 +1,16 @@ +using LyricFlow.Core.Dtos; + +namespace LyricFlow.Core.Storage; + +public record Snapshot( + int Id, + string FilePath, + string Content, + double Timestamp +) +{ + public SnapshotDto ToDto() + { + return new SnapshotDto(Id, FilePath, Content, Timestamp); + } +} diff --git a/LyricFlow.Core.Backend/LyricFlow.Core/Storage/StorageService.cs b/LyricFlow.Core.Backend/LyricFlow.Core/Storage/StorageService.cs new file mode 100644 index 0000000..d81f779 --- /dev/null +++ b/LyricFlow.Core.Backend/LyricFlow.Core/Storage/StorageService.cs @@ -0,0 +1,142 @@ +using Microsoft.Data.Sqlite; +using System.IO; + +namespace LyricFlow.Core.Storage; + +public class StorageService +{ + private readonly string _dbPath; + + // MARK: - Lifecycle + #region Lifecycle + + public StorageService(string dbPath) + { + _dbPath = dbPath; + InitializeDatabase(); + } + + #endregion + + // MARK: - Database Setup + #region Database Setup + + private SqliteConnection GetConnection() + { + var connection = new SqliteConnection($"Data Source={_dbPath}"); + connection.Open(); + return connection; + } + + private void InitializeDatabase() + { + var absolutePath = Path.GetFullPath(_dbPath); + var dbDirectory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(dbDirectory)) + { + Directory.CreateDirectory(dbDirectory); + } + + using var connection = GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = @" + CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + content TEXT NOT NULL, + timestamp REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS scratchpads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT UNIQUE NOT NULL, + content TEXT NOT NULL, + last_modified REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_file ON snapshots(file_path); + "; + command.ExecuteNonQuery(); + } + + #endregion + + // MARK: - Snapshot Persistence + #region Snapshot Persistence + + public async Task SaveSnapshotAsync(string filePath, string content) + { + if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(content)) + return; + + using var connection = GetConnection(); + + using (var checkCommand = connection.CreateCommand()) + { + checkCommand.CommandText = "SELECT content FROM snapshots WHERE file_path = @filePath ORDER BY timestamp DESC LIMIT 1"; + checkCommand.Parameters.AddWithValue("@filePath", filePath); + var lastContent = (string?)await checkCommand.ExecuteScalarAsync(); + if (lastContent == content) return; + } + + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO snapshots (file_path, content, timestamp) VALUES (@filePath, @content, @timestamp)"; + command.Parameters.AddWithValue("@filePath", filePath); + command.Parameters.AddWithValue("@content", content); + command.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d); + await command.ExecuteNonQueryAsync(); + } + + public async Task> GetSnapshotsAsync(string filePath) + { + var result = new List(); + using var connection = GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, file_path, content, timestamp FROM snapshots WHERE file_path = @filePath ORDER BY timestamp DESC"; + command.Parameters.AddWithValue("@filePath", filePath); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add(new Snapshot( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.GetDouble(3) + )); + } + return result; + } + + #endregion + + // MARK: - Scratchpad Persistence + #region Scratchpad Persistence + + public async Task SaveScratchpadAsync(string projectId, string content) + { + using var connection = GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = @" + INSERT INTO scratchpads (project_id, content, last_modified) + VALUES (@projectId, @content, @timestamp) + ON CONFLICT(project_id) DO UPDATE SET + content=excluded.content, + last_modified=excluded.last_modified + "; + command.Parameters.AddWithValue("@projectId", projectId); + command.Parameters.AddWithValue("@content", content); + command.Parameters.AddWithValue("@timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d); + await command.ExecuteNonQueryAsync(); + } + + public async Task GetScratchpadAsync(string projectId) + { + using var connection = GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT content FROM scratchpads WHERE project_id = @projectId"; + command.Parameters.AddWithValue("@projectId", projectId); + var result = (string?)await command.ExecuteScalarAsync(); + return result ?? ""; + } + + #endregion +} diff --git a/lyricflow.spec b/lyricflow.spec index 53e73ca..5cf5f10 100644 --- a/lyricflow.spec +++ b/lyricflow.spec @@ -14,42 +14,72 @@ for f in os.listdir(pyqt6_path): sip_binary = (os.path.join(pyqt6_path, f), 'PyQt6') break -# Get NLTK data path from environment or default locations -import nltk -nltk_data_paths = nltk.data.path -nltk_path = None -for p in nltk_data_paths: - if os.path.exists(p): - nltk_path = p - break +datas = [] -datas = [ - ('src', 'src'), - ('assets', 'assets'), - ('data', 'data'), -] +backend_publish_dir = os.path.join('build', 'publish', 'backend') +if os.path.isdir(backend_publish_dir): + for root, _, files in os.walk(backend_publish_dir): + for file_name in files: + source_path = os.path.join(root, file_name) + relative_root = os.path.relpath(root, backend_publish_dir) + target_root = 'backend' if relative_root == '.' else os.path.join('backend', relative_root) + datas.append((source_path, target_root)) -if nltk_path: +def _candidate_nltk_roots(): + roots = [] + env_path = os.environ.get('NLTK_DATA') + if env_path: + roots.extend(p for p in env_path.split(os.pathsep) if p) + + appdata = os.environ.get('APPDATA') + if appdata: + roots.append(os.path.join(appdata, 'nltk_data')) + + home = os.path.expanduser('~') + if home: + roots.append(os.path.join(home, 'nltk_data')) + + return roots + + +for nltk_path in _candidate_nltk_roots(): cmudict_path = os.path.join(nltk_path, 'corpora', 'cmudict') cmudict_zip = os.path.join(nltk_path, 'corpora', 'cmudict.zip') wordnet_zip = os.path.join(nltk_path, 'corpora', 'wordnet.zip') + added_any = False if os.path.exists(cmudict_path): datas.append((cmudict_path, 'nltk_data/corpora/cmudict')) + added_any = True if os.path.exists(cmudict_zip): datas.append((cmudict_zip, 'nltk_data/corpora')) + added_any = True if os.path.exists(wordnet_zip): datas.append((wordnet_zip, 'nltk_data/corpora')) + added_any = True + if added_any: + break a = Analysis( ['run.py'], - pathex=[os.path.abspath('src')], + pathex=[os.path.abspath('.')], binaries=[sip_binary] if sip_binary else [], datas=datas, - hiddenimports=['PyQt6.sip', 'nltk.corpus.wordnet', 'nltk.corpus.cmudict'], + hiddenimports=['PyQt6.sip', 'src.gui.main_window', 'src.gui.backend_runner'], hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[], + excludes=[ + 'IPython', + 'matplotlib', + 'nltk', + 'pygame', + 'pygame_ce', + 'pytest', + 'requests', + 'sympy', + 'tkinter', + '_pytest', + ], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, diff --git a/requirements.txt b/requirements.txt index 76f6b83..271949e 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/run.py b/run.py index baa2372..07802b6 100644 --- a/run.py +++ b/run.py @@ -1,29 +1,41 @@ import sys -import os -import nltk +from pathlib import Path -# Add bundled nltk_data path if running as executable -if getattr(sys, 'frozen', False): - bundle_dir = sys._MEIPASS - nltk_data_path = os.path.join(bundle_dir, 'nltk_data') - if nltk_data_path not in nltk.data.path: - nltk.data.path.append(nltk_data_path) +# MARK: - Import Bootstrap -# Add src to path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src'))) +PROJECT_ROOT = Path(__file__).resolve().parent +if not getattr(sys, "frozen", False): + sys.path.insert(0, str(PROJECT_ROOT)) -from gui.main_window import MainWindow +from src.gui.main_window import MainWindow +from src.gui.backend_runner import BackendRunner from PyQt6.QtWidgets import QApplication from PyQt6.QtCore import QCoreApplication + +# MARK: - Application Entry Point + def main(): app = QApplication(sys.argv) QCoreApplication.setOrganizationName("LyricFlow") QCoreApplication.setOrganizationDomain("lyricflow.local") QCoreApplication.setApplicationName("LyricFlow") + + backend_runner = BackendRunner() + try: + backend_runner.start() + except Exception as e: + print(f"Failed to start C# backend: {e}") + window = MainWindow() window.show() - sys.exit(app.exec()) + + exit_code = app.exec() + + # Cleanup backend + backend_runner.stop() + + sys.exit(exit_code) if __name__ == "__main__": main() diff --git a/src/gui/backend_client.py b/src/gui/backend_client.py new file mode 100644 index 0000000..94e92a3 --- /dev/null +++ b/src/gui/backend_client.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import json +import re +from typing import Any +from urllib import error, parse, request + + +# MARK: - Client DTOs + +@dataclass +class AppCorePreferences: + reopen_last_project: bool = True + restore_unsaved_tabs: bool = True + last_project_file: str = "" + + +@dataclass +class ProjectState: + version: int = 2 + name: str = "" + open_files: list[str] | None = None + active_file: str | None = None + cursor_positions: dict[str, int] | None = None + scratchpad_open: bool = False + + def to_payload(self) -> dict[str, Any]: + return { + "version": self.version, + "name": self.name, + "open_files": list(self.open_files or []), + "active_file": self.active_file, + "cursor_positions": dict(self.cursor_positions or {}), + "scratchpad_open": self.scratchpad_open, + } + + +@dataclass +class SessionTabSnapshot: + tab_id: str + file_path: str | None + display_name: str + content: str + cursor_position: int + is_dirty: bool + is_untitled: bool + snapshot_mtime: float | None + workspace_root: str | None + updated_at: str + + @classmethod + def from_payload(cls, data: dict[str, Any]) -> "SessionTabSnapshot": + return cls( + tab_id=str(data.get("tab_id", "")), + file_path=data.get("file_path") if isinstance(data.get("file_path"), str) else None, + display_name=str(data.get("display_name", "Recovered")), + content=str(data.get("content", "")), + cursor_position=max(0, int(data.get("cursor_position", 0))), + is_dirty=bool(data.get("is_dirty", False)), + is_untitled=bool(data.get("is_untitled", False)), + snapshot_mtime=_to_float_or_none(data.get("snapshot_mtime")), + workspace_root=data.get("workspace_root") if isinstance(data.get("workspace_root"), str) else None, + updated_at=str(data.get("updated_at", datetime.now(timezone.utc).isoformat())), + ) + + +@dataclass +class Snapshot: + id: int + file_path: str + content: str + timestamp: float + + +@dataclass +class FileReadResult: + success: bool + message: str + content: str | None + path: str | None + + +@dataclass +class FileWriteResult: + success: bool + message: str + path: str | None + + +# MARK: - Backend Client + +class DesktopBackendClient: + # MARK: - Lifecycle + + def __init__(self, base_url: str = "http://127.0.0.1:5000"): + self.base_url = base_url.rstrip("/") + self._phoneme_cache: dict[str, tuple[tuple[str, ...], ...]] = {} + self._syllable_cache: dict[str, int] = {} + self._suggestions_cache: dict[tuple[str, int], dict[str, list[str]]] = {} + self._synonyms_cache: dict[tuple[str, int], dict[str, list[str]]] = {} + self._known_word_cache: dict[str, bool] = {} + self._autocorrect_cache: dict[str, str | None] = {} + + # MARK: - Health And Normalization + + def is_alive(self) -> bool: + try: + self._request_json("/api/health", timeout=0.5) + return True + except RuntimeError: + return False + + def health(self) -> dict[str, Any]: + return self._get_json("/api/health", default={"status": "offline", "ready": False}) + + def normalize_word(self, word: str) -> str: + normalized = re.sub(r"[^a-z']", "", word.lower().strip()) + if normalized.endswith("in'"): + normalized = normalized[:-1] + "g" + return normalized + + # MARK: - Analysis + + def phonemes(self, word: str) -> tuple[tuple[str, ...], ...]: + normalized = self.normalize_word(word) + if normalized in self._phoneme_cache: + return self._phoneme_cache[normalized] + payload = self._get_json("/api/analysis/phonemes", params={"word": word}, default=[]) + phonemes = tuple(tuple(item) for item in payload if isinstance(item, list)) + self._phoneme_cache[normalized] = phonemes + return phonemes + + def count_syllables(self, word: str) -> int: + normalized = self.normalize_word(word) + if normalized in self._syllable_cache: + return self._syllable_cache[normalized] + payload = self._get_json("/api/analysis/syllables", params={"word": word}, default={"count": 0}) + count = int(payload.get("count", 0)) + self._syllable_cache[normalized] = count + return count + + def rhyme_groups(self, text: str) -> list[dict[str, Any]]: + return self._post_json("/api/analysis/rhyme-groups", json={"text": text}, default=[]) + + def line_densities(self, text: str) -> list[float]: + payload = self._post_json("/api/analysis/line-densities", json={"text": text}, default=[]) + return [float(value) for value in payload] + + def suggestions(self, word: str, limit: int = 20) -> dict[str, list[str]]: + normalized = self.normalize_word(word) + cache_key = (normalized, limit) + if cache_key in self._suggestions_cache: + return self._suggestions_cache[cache_key] + payload = self._get_json( + "/api/analysis/suggestions", + params={"word": word, "limit": limit}, + default={"perfect": [], "slant": []}, + ) + result = { + "perfect": [str(value) for value in payload.get("perfect", [])], + "slant": [str(value) for value in payload.get("slant", [])], + } + self._suggestions_cache[cache_key] = result + return result + + def synonyms(self, word: str, limit: int = 15) -> dict[str, list[str]]: + normalized = self.normalize_word(word) + cache_key = (normalized, limit) + if cache_key in self._synonyms_cache: + return self._synonyms_cache[cache_key] + payload = self._get_json( + "/api/analysis/synonyms", + params={"word": word, "limit": limit}, + default={"synonyms": [], "vibe": []}, + ) + result = { + "synonyms": [str(value) for value in payload.get("synonyms", [])], + "vibe": [str(value) for value in payload.get("vibe", [])], + } + self._synonyms_cache[cache_key] = result + return result + + # MARK: - Spellcheck + + def is_known_word(self, word: str) -> bool: + normalized = self.normalize_word(word) + if normalized in self._known_word_cache: + return self._known_word_cache[normalized] + payload = self._get_json("/api/spellcheck/known", params={"word": word}, default={"is_known": False}) + is_known = bool(payload.get("is_known", payload.get("isKnown", False))) + self._known_word_cache[normalized] = is_known + return is_known + + def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]: + payload = self._get_json("/api/spellcheck/suggestions", params={"word": word, "limit": limit}, default=[]) + return [str(value) for value in payload] + + def autocorrect_candidate(self, word: str) -> str | None: + normalized = self.normalize_word(word) + if normalized in self._autocorrect_cache: + return self._autocorrect_cache[normalized] + payload = self._get_json("/api/spellcheck/autocorrect", params={"word": word}, default={"candidate": None}) + candidate = payload.get("candidate") + result = candidate if isinstance(candidate, str) and candidate else None + self._autocorrect_cache[normalized] = result + return result + + def spelling_issues(self, text: str, suggestion_limit: int = 6) -> list[dict[str, Any]]: + payload = self._post_json( + "/api/spellcheck/issues", + json={"text": text}, + params={"limit": suggestion_limit}, + default=[], + ) + return [item for item in payload if isinstance(item, dict)] + + # MARK: - Preferences And Projects + + def load_core_preferences(self) -> AppCorePreferences: + payload = self._get_json("/api/settings", default={}) + return AppCorePreferences( + reopen_last_project=bool(payload.get("reopen_last_project", True)), + restore_unsaved_tabs=bool(payload.get("restore_unsaved_tabs", True)), + last_project_file=str(payload.get("last_project_file", "")), + ) + + def save_core_preferences(self, prefs: AppCorePreferences) -> None: + self._post_json("/api/settings", json=asdict(prefs), default={"success": False}) + + def read_project(self, project_file: str) -> ProjectState: + payload = self._post_json("/api/projects/read", json={"project_file": project_file}, default={}) + return ProjectState( + version=int(payload.get("version", 2)), + name=str(payload.get("name", "")), + open_files=[str(value) for value in payload.get("open_files", []) if isinstance(value, str)], + active_file=payload.get("active_file") if isinstance(payload.get("active_file"), str) else None, + cursor_positions={ + str(path): max(0, int(position)) + for path, position in (payload.get("cursor_positions", {}) or {}).items() + if isinstance(path, str) + }, + scratchpad_open=bool(payload.get("scratchpad_open", False)), + ) + + def write_project(self, project_file: str, state: ProjectState) -> None: + self._post_json( + "/api/projects/write", + json={"project_file": project_file, "state": state.to_payload()}, + default={"success": False}, + ) + + # MARK: - Session Persistence + + def load_session(self, workspace_root: str | None) -> list[SessionTabSnapshot]: + payload = self._post_json("/api/session/load", json={"workspace_root": workspace_root}, default=[]) + return [SessionTabSnapshot.from_payload(item) for item in payload if isinstance(item, dict)] + + def save_session(self, workspace_root: str | None, snapshots: list[SessionTabSnapshot]) -> None: + self._post_json( + "/api/session/save", + json={ + "workspace_root": workspace_root, + "snapshots": [asdict(snapshot) for snapshot in snapshots], + }, + default={"success": False}, + ) + + def clear_session(self, workspace_root: str | None = None) -> None: + self._post_json("/api/session/clear", json={"workspace_root": workspace_root}, default={"success": False}) + + # MARK: - Workspace Files + + def read_file(self, path: str) -> FileReadResult: + payload = self._post_json("/api/files/read", json={"path": path}, default={}) + return FileReadResult( + success=bool(payload.get("success", False)), + message=str(payload.get("message", "")), + content=payload.get("content") if isinstance(payload.get("content"), str) else None, + path=payload.get("path") if isinstance(payload.get("path"), str) else None, + ) + + def write_file(self, path: str, content: str) -> FileWriteResult: + payload = self._post_json("/api/files/write", json={"path": path, "content": content}, default={}) + return FileWriteResult( + success=bool(payload.get("success", False)), + message=str(payload.get("message", "")), + path=payload.get("path") if isinstance(payload.get("path"), str) else None, + ) + + def create_entry(self, path: str, is_directory: bool = False) -> FileWriteResult: + payload = self._post_json( + "/api/files/create", + json={"path": path, "is_directory": is_directory}, + default={}, + ) + return FileWriteResult( + success=bool(payload.get("success", False)), + message=str(payload.get("message", "")), + path=payload.get("path") if isinstance(payload.get("path"), str) else None, + ) + + def rename_entry(self, old_path: str, new_path: str, root_path: str | None) -> FileWriteResult: + payload = self._post_json( + "/api/files/rename", + json={"old_path": old_path, "new_path": new_path, "root_path": root_path}, + default={}, + ) + return FileWriteResult( + success=bool(payload.get("success", False)), + message=str(payload.get("message", "")), + path=payload.get("path") if isinstance(payload.get("path"), str) else None, + ) + + def delete_entry(self, path: str, root_path: str | None) -> FileWriteResult: + payload = self._post_json( + "/api/files/delete", + json={"path": path, "root_path": root_path}, + default={}, + ) + return FileWriteResult( + success=bool(payload.get("success", False)), + message=str(payload.get("message", "")), + path=payload.get("path") if isinstance(payload.get("path"), str) else None, + ) + + # MARK: - History And Scratchpad + + def save_snapshot(self, file_path: str, content: str) -> None: + self._post_json("/api/history/snapshots", json={"file_path": file_path, "content": content}, default={}) + + def get_snapshots(self, file_path: str) -> list[Snapshot]: + payload = self._get_json("/api/history/snapshots", params={"filePath": file_path}, default=[]) + return [ + Snapshot( + id=int(item.get("id", 0)), + file_path=str(item.get("file_path", "")), + content=str(item.get("content", "")), + timestamp=float(item.get("timestamp", 0)), + ) + for item in payload + if isinstance(item, dict) + ] + + def save_scratchpad(self, project_id: str, content: str) -> None: + self._post_json("/api/scratchpad", json={"project_id": project_id, "content": content}, default={}) + + def get_scratchpad(self, project_id: str) -> str: + payload = self._get_json(f"/api/scratchpad/{project_id}", default={"content": ""}) + return str(payload.get("content", "")) + + # MARK: - HTTP Helpers + + def _get_json(self, path: str, params: dict[str, Any] | None = None, default: Any = None) -> Any: + try: + return self._request_json(path, params=params, timeout=5.0, default=default) + except RuntimeError: + return default + + def _post_json( + self, + path: str, + json: dict[str, Any], + params: dict[str, Any] | None = None, + default: Any = None, + ) -> Any: + try: + return self._request_json( + path, + method="POST", + payload=json, + params=params, + timeout=10.0, + default=default, + ) + except RuntimeError: + return default + + def _request_json( + self, + path: str, + method: str = "GET", + payload: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + timeout: float = 5.0, + default: Any = None, + ) -> Any: + query = f"?{parse.urlencode(params, doseq=True)}" if params else "" + body = None + headers: dict[str, str] = {} + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = request.Request(f"{self.base_url}{path}{query}", data=body, headers=headers, method=method) + try: + with request.urlopen(req, timeout=timeout) as response: + content = response.read() + except (error.HTTPError, error.URLError, TimeoutError, OSError) as exc: + raise RuntimeError("Backend request failed") from exc + + if not content: + return default + + try: + return json.loads(content.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Backend returned invalid JSON") from exc + + +desktop_client = DesktopBackendClient() + + +# MARK: - Utility Helpers + +def _to_float_or_none(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None diff --git a/src/gui/backend_runner.py b/src/gui/backend_runner.py new file mode 100644 index 0000000..d3ae52f --- /dev/null +++ b/src/gui/backend_runner.py @@ -0,0 +1,153 @@ +import os +import subprocess +import sys +import time +from pathlib import Path +from urllib import error, request + + +# MARK: - Backend Process Runner + +class BackendRunner: + # MARK: - Lifecycle + + def __init__(self, base_url: str = "http://127.0.0.1:5000"): + self.base_url = base_url.rstrip("/") + self._process: subprocess.Popen | None = None + + def start(self) -> None: + if self.is_alive(): + return + + executable = self._resolve_executable() + if executable is None or self._needs_build(executable): + self._build_backend() + executable = self._resolve_executable() + + if executable is None: + raise RuntimeError("LyricFlow backend executable was not found after build.") + + creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + env = self._build_environment() + env["Backend__Urls"] = self.base_url + self._process = subprocess.Popen( + [str(executable)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=creationflags, + env=env, + ) + self._wait_until_ready() + + def stop(self) -> None: + if self._process is None: + return + + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=5) + finally: + self._process = None + + # MARK: - Health And Readiness + + def is_alive(self) -> bool: + try: + with request.urlopen(f"{self.base_url}/api/health", timeout=0.5) as response: + return response.status == 200 + except (error.HTTPError, error.URLError, TimeoutError, OSError): + return False + + def _wait_until_ready(self, retries: int = 40, delay: float = 0.25) -> None: + for _ in range(retries): + if self.is_alive(): + return + time.sleep(delay) + raise RuntimeError("LyricFlow backend did not become ready in time.") + + # MARK: - Executable Resolution + + def _resolve_executable(self) -> Path | None: + env_path = os.getenv("LYRICFLOW_BACKEND_BIN") + if env_path: + candidate = Path(env_path).expanduser().resolve() + if candidate.exists(): + return candidate + + for candidate in self._bundled_candidates(): + if candidate.exists(): + return candidate + + repo_root = Path(__file__).resolve().parents[2] + project_dir = repo_root / "LyricFlow.Core.Backend" / "LyricFlow.Backend.Api" + runtime_name = "LyricFlow.Backend.Api.exe" if os.name == "nt" else "LyricFlow.Backend.Api" + candidates = [ + project_dir / "bin" / "Debug" / "net10.0" / runtime_name, + project_dir / "bin" / "Release" / "net10.0" / runtime_name, + ] + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + # MARK: - Build And Freshness + + def _build_backend(self) -> None: + if getattr(sys, "frozen", False): + raise RuntimeError("Bundled build is missing the packaged backend executable.") + + repo_root = Path(__file__).resolve().parents[2] + project_file = repo_root / "LyricFlow.Core.Backend" / "LyricFlow.Backend.Api" / "LyricFlow.Backend.Api.csproj" + subprocess.run( + ["dotnet", "build", str(project_file)], + cwd=repo_root, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def _needs_build(self, executable: Path) -> bool: + repo_root = Path(__file__).resolve().parents[2] + source_roots = [ + repo_root / "LyricFlow.Core.Backend" / "LyricFlow.Backend.Api", + repo_root / "LyricFlow.Core.Backend" / "LyricFlow.Core", + ] + try: + executable_mtime = executable.stat().st_mtime + except OSError: + return True + + for source_root in source_roots: + for pattern in ("*.cs", "*.csproj", "*.json"): + for path in source_root.rglob(pattern): + try: + if path.stat().st_mtime > executable_mtime: + return True + except OSError: + continue + return False + + def _bundled_candidates(self) -> list[Path]: + runtime_name = "LyricFlow.Backend.Api.exe" if os.name == "nt" else "LyricFlow.Backend.Api" + candidates: list[Path] = [] + + if getattr(sys, "frozen", False): + executable_dir = Path(sys.executable).resolve().parent + candidates.append(executable_dir / "backend" / runtime_name) + candidates.append(executable_dir / "_internal" / "backend" / runtime_name) + + bundle_dir = getattr(sys, "_MEIPASS", None) + if bundle_dir: + candidates.append(Path(bundle_dir).resolve() / "backend" / runtime_name) + + return candidates + + # MARK: - Runtime Environment + + def _build_environment(self) -> dict[str, str]: + return os.environ.copy() diff --git a/src/gui/components/editor.py b/src/gui/components/editor.py index c74bcdf..38cf173 100644 --- a/src/gui/components/editor.py +++ b/src/gui/components/editor.py @@ -1,13 +1,21 @@ -from PyQt6.QtWidgets import QPlainTextEdit, QWidget -from PyQt6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QTextCursor, QPainter -from PyQt6.QtCore import QTimer, pyqtSignal, QRect, Qt -from src.lyricflow_core.api.analysis import analysis_service -from typing import Optional, List, Tuple, Dict +from PyQt6.QtWidgets import QMenu, QPlainTextEdit, QWidget +from PyQt6.QtGui import QAction, QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QTextCursor, QPainter +from PyQt6.QtCore import QTimer, pyqtSignal, Qt +from typing import Optional, List, Dict import re -from src.lyricflow_core.engine.syntax import TAG_PATTERN +from src.gui.backend_client import desktop_client +from src.gui.lyricdown import TAG_PATTERN from src.gui.theme import Theme +WORD_PATTERN = re.compile(r"\b\w+\b") +BOLD_PATTERN = re.compile(r"\*\*(.*?)\*\*") +ITALIC_PATTERN = re.compile(r"\*(.*?)\*") +SYLLABLE_WORD_PATTERN = re.compile(r"[A-Za-z']+") + + +# MARK: - Syntax Highlighting + class RhymeHighlighter(QSyntaxHighlighter): def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) @@ -71,16 +79,16 @@ class RhymeHighlighter(QSyntaxHighlighter): excluded_ranges.append((start, end)) # 5. Bold (**text**) - for match in re.finditer(r"\*\*(.*?)\*\*", text): + for match in BOLD_PATTERN.finditer(text): self.setFormat(match.start(), len(match.group()), bold_fmt) # 6. Italic (*text*) - for match in re.finditer(r"\*(.*?)\*", text): + for match in ITALIC_PATTERN.finditer(text): self.setFormat(match.start(), len(match.group()), italic_fmt) # 7. Highlight Rhymes if self.rhyme_map: - for match in re.finditer(r"\b\w+\b", text): + for match in WORD_PATTERN.finditer(text): word = match.group() start = match.start() @@ -103,17 +111,17 @@ class RhymeHighlighter(QSyntaxHighlighter): # 8. Spellcheck overlay if self.spellcheck_enabled: - for match in re.finditer(r"\b\w+\b", text): + for match in WORD_PATTERN.finditer(text): word = match.group() start = match.start() is_excluded = any(ex_start <= start < ex_end for ex_start, ex_end in excluded_ranges) if is_excluded: continue - normalized = analysis_service.normalize_word(word) + normalized = desktop_client.normalize_word(word) if not normalized: continue - if analysis_service.is_known_word(normalized): + if desktop_client.is_known_word(normalized): continue existing = self.format(start) @@ -122,10 +130,16 @@ class RhymeHighlighter(QSyntaxHighlighter): fmt.setUnderlineColor(QColor(Theme.SPELLCHECK_ERROR)) self.setFormat(start, len(word), fmt) + +# MARK: - Editor Widget + class LyricEditor(QPlainTextEdit): textChangedDebounced = pyqtSignal(str) wordSelected = pyqtSignal(str) _AUTOCORRECT_DELIMITERS = set(" \t.,!?;:)]}\"'") + _SUGGESTION_LIMIT = 5 + + # MARK: - Lifecycle def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) @@ -134,6 +148,8 @@ class LyricEditor(QPlainTextEdit): self.autocorrect_enabled = True self._last_emitted_text: Optional[str] = None self._last_analyzed_text: Optional[str] = None + self._line_syllable_counts: list[int] = [] + self._suggestion_menu: QMenu | None = None self.timer = QTimer() self.timer.setSingleShot(True) @@ -149,14 +165,20 @@ class LyricEditor(QPlainTextEdit): font.setStyleHint(QFont.StyleHint.Monospace) self.setFont(font) + # MARK: - Input Handling + def keyPressEvent(self, e): + if e.modifiers() == Qt.KeyboardModifier.ControlModifier and e.key() == Qt.Key.Key_Period: + self._show_spelling_suggestions_at_cursor() + return + super().keyPressEvent(e) if not self.autocorrect_enabled: return typed = e.text() if typed and typed in self._AUTOCORRECT_DELIMITERS: - self._autocorrect_previous_word() + self._suggest_previous_word() def wheelEvent(self, e): if e.modifiers() == Qt.KeyboardModifier.ControlModifier: @@ -173,6 +195,8 @@ class LyricEditor(QPlainTextEdit): def zoom_out(self): self.zoomOut(1) + # MARK: - Line Editing + def move_line_up(self): cursor = self.textCursor() if not cursor.hasSelection(): @@ -252,6 +276,8 @@ class LyricEditor(QPlainTextEdit): if cursor.position() > end: break + # MARK: - Rendering + def paintEvent(self, e): super().paintEvent(e) painter = QPainter(self.viewport()) @@ -261,6 +287,8 @@ class LyricEditor(QPlainTextEdit): font = self.font() font.setPointSize(max(8, font.pointSize() - 2)) painter.setFont(font) + rect = self.viewport().rect() + ascent = self.fontMetrics().ascent() block = self.firstVisibleBlock() top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() @@ -268,18 +296,10 @@ class LyricEditor(QPlainTextEdit): while block.isValid() and top <= e.rect().bottom(): if block.isVisible() and bottom >= e.rect().top(): - text = block.text().strip() - if text and not (text.startswith('[') and text.endswith(']')): - # Count syllables for the whole line - words = re.findall(r"\b\w+\b", text) if ' ' in text else [text] - # Robust extraction for syllable summation - words = [w for w in re.split(r'\s+', text) if w and not w.startswith('[')] - count = sum(analysis_service.count_syllables(w) for w in words) - - if count > 0: - # Draw on the right side - rect = self.viewport().rect() - painter.drawText(rect.width() - 40, int(top) + self.fontMetrics().ascent(), str(count)) + block_number = block.blockNumber() + count = self._line_syllable_counts[block_number] if block_number < len(self._line_syllable_counts) else 0 + if count > 0: + painter.drawText(rect.width() - 40, int(top) + ascent, str(count)) block = block.next() top = bottom @@ -288,7 +308,25 @@ class LyricEditor(QPlainTextEdit): def _on_text_changed(self): self.timer.start() - def _autocorrect_previous_word(self): + # MARK: - Suggestion UI + + def contextMenuEvent(self, e): + menu = self.createStandardContextMenu() + word_info = self._current_word_span() + if word_info: + start_abs, end_abs, original = word_info + suggestions = self._spelling_suggestions_for_word(original) + if suggestions: + menu.addSeparator() + for suggestion in suggestions[: self._SUGGESTION_LIMIT]: + action = QAction(f"Replace with '{suggestion}'", menu) + action.triggered.connect( + lambda checked=False, replacement=suggestion, start=start_abs, end=end_abs: self._replace_range_text(start, end, replacement) + ) + menu.addAction(action) + menu.exec(e.globalPos()) + + def _suggest_previous_word(self): cursor = self.textCursor() block = cursor.block() block_text = block.text() @@ -309,6 +347,18 @@ class LyricEditor(QPlainTextEdit): if block_text[delimiter_idx] not in self._AUTOCORRECT_DELIMITERS: return + word_info = self._word_span_before_delimiter(block, block_text, delimiter_idx) + if not word_info: + return + + start_abs, end_abs, original = word_info + suggestions = self._spelling_suggestions_for_word(original) + if not suggestions: + return + + self._show_suggestion_menu(start_abs, end_abs, suggestions) + + def _word_span_before_delimiter(self, block, block_text: str, delimiter_idx: int) -> tuple[int, int, str] | None: excluded_ranges = [(m.start(), m.end()) for m in re.finditer(TAG_PATTERN, block_text)] if any(start <= delimiter_idx < end for start, end in excluded_ranges): return @@ -332,24 +382,96 @@ class LyricEditor(QPlainTextEdit): if any(start <= word_start < end for start, end in excluded_ranges): return original = block_text[word_start:word_end] - suggestion = analysis_service.autocorrect_candidate(original) - if not suggestion: + return (block.position() + word_start, block.position() + word_end, original) + + def _current_word_span(self) -> tuple[int, int, str] | None: + cursor = self.textCursor() + block = cursor.block() + block_text = block.text() + stripped = block_text.lstrip() + if stripped.startswith(("#", "@", ">")): + return None + + block_pos = block.position() + cursor_pos_in_block = cursor.position() - block_pos + excluded_ranges = [(m.start(), m.end()) for m in re.finditer(TAG_PATTERN, block_text)] + if any(start <= cursor_pos_in_block < end for start, end in excluded_ranges): + return None + + probe = QTextCursor(cursor) + probe.select(QTextCursor.SelectionType.WordUnderCursor) + original = probe.selectedText().strip() + if not original: + return None + + selection_start = probe.selectionStart() - block_pos + if any(start <= selection_start < end for start, end in excluded_ranges): + return None + + return (probe.selectionStart(), probe.selectionEnd(), original) + + def _show_spelling_suggestions_at_cursor(self): + word_info = self._current_word_span() + if not word_info: return - if original.isupper(): - replacement = suggestion.upper() - elif original[0].isupper(): - replacement = suggestion.capitalize() - else: - replacement = suggestion - - if replacement == original: + start_abs, end_abs, original = word_info + suggestions = self._spelling_suggestions_for_word(original) + if not suggestions: return - start_abs = block.position() + word_start - end_abs = block.position() + word_end + self._show_suggestion_menu(start_abs, end_abs, suggestions) + + def _spelling_suggestions_for_word(self, original: str) -> list[str]: + normalized = desktop_client.normalize_word(original) + if not normalized or desktop_client.is_known_word(normalized): + return [] + + suggestions = desktop_client.spelling_suggestions(normalized, limit=self._SUGGESTION_LIMIT) + if not suggestions: + return [] + + transformed: list[str] = [] + for suggestion in suggestions: + if original.isupper(): + transformed.append(suggestion.upper()) + elif original[0].isupper(): + transformed.append(suggestion.capitalize()) + else: + transformed.append(suggestion) + return [suggestion for suggestion in transformed if suggestion != original] + + def _show_suggestion_menu(self, start_abs: int, end_abs: int, suggestions: list[str]): + if self._suggestion_menu is not None: + self._suggestion_menu.close() + + menu = QMenu(self) + menu.setStyleSheet( + f""" + QMenu {{ background-color: {Theme.BACKGROUND_SECONDARY}; color: {Theme.FOREGROUND}; border: 1px solid {Theme.CURRENT_LINE}; }} + QMenu::item:selected {{ background-color: {Theme.CURRENT_LINE}; }} + """ + ) + for suggestion in suggestions[: self._SUGGESTION_LIMIT]: + action = menu.addAction(suggestion) + action.triggered.connect( + lambda checked=False, replacement=suggestion, start=start_abs, end=end_abs: self._replace_range_text(start, end, replacement) + ) + menu.addSeparator() + dismiss_action = menu.addAction("Keep original") + dismiss_action.triggered.connect(menu.close) + + anchor = QTextCursor(self.document()) + anchor.setPosition(end_abs) + global_pos = self.viewport().mapToGlobal(self.cursorRect(anchor).bottomRight()) + self._suggestion_menu = menu + menu.aboutToHide.connect(self._clear_suggestion_menu) + menu.popup(global_pos) + + def _replace_range_text(self, start_abs: int, end_abs: int, replacement: str): + cursor = self.textCursor() old_pos = cursor.position() - delta = len(replacement) - len(original) + delta = len(replacement) - (end_abs - start_abs) edit_cursor = self.textCursor() edit_cursor.beginEditBlock() @@ -362,11 +484,17 @@ class LyricEditor(QPlainTextEdit): final_cursor.setPosition(max(0, old_pos + delta)) self.setTextCursor(final_cursor) + def _clear_suggestion_menu(self): + self._suggestion_menu = None + + # MARK: - Analysis Signals + def _emit_debounced(self): text = self.toPlainText() if text == self._last_emitted_text: return self._last_emitted_text = text + # TODO(analysis): extend the debounced payload with selection bounds for selection-only analysis from docs/lyricflow.md. self.textChangedDebounced.emit(text) def _on_cursor_moved(self): @@ -397,19 +525,40 @@ class LyricEditor(QPlainTextEdit): if word: self.wordSelected.emit(word) + # MARK: - Analysis Caching + def _analyze(self, text: str): if text == self._last_analyzed_text: return if not text: self.highlighter.set_results([]) + self._line_syllable_counts = [] v = self.viewport() if v: v.update() self._last_analyzed_text = text return - results = analysis_service.rhyme_groups(text) + results = desktop_client.rhyme_groups(text) self.highlighter.set_results(results) + self._line_syllable_counts = self._compute_line_syllable_counts(text) v = self.viewport() if v: v.update() # Redraw syllables self._last_analyzed_text = text + + def _compute_line_syllable_counts(self, text: str) -> list[int]: + counts: list[int] = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "@", ">")): + counts.append(0) + continue + + analysis_text = re.sub(TAG_PATTERN, "", line) + words = SYLLABLE_WORD_PATTERN.findall(analysis_text) + if not words: + counts.append(0) + continue + + counts.append(sum(desktop_client.count_syllables(word) for word in words)) + return counts diff --git a/src/gui/components/explorer.py b/src/gui/components/explorer.py index caf09cb..128da43 100644 --- a/src/gui/components/explorer.py +++ b/src/gui/components/explorer.py @@ -1,11 +1,13 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTreeView, QLabel, QMenu, QInputDialog, QMessageBox) from PyQt6.QtGui import QFileSystemModel -from PyQt6.QtCore import QDir, pyqtSignal, QModelIndex, Qt, QStandardPaths -from datetime import datetime +from PyQt6.QtCore import QDir, pyqtSignal, QModelIndex, Qt import os -import shutil +from src.gui.backend_client import DesktopBackendClient, desktop_client + + +# MARK: - Path Validation def _is_valid_entry_name(name: str) -> bool: cleaned = name.strip() @@ -25,11 +27,16 @@ def _is_within_root(root_path: str, candidate_path: str) -> bool: return False +# MARK: - Explorer Widget + class ProjectExplorer(QWidget): fileSelected = pyqtSignal(str) - def __init__(self, parent=None): + # MARK: - Lifecycle + + def __init__(self, client: DesktopBackendClient | None = None, parent=None): super().__init__(parent) + self.client = client or desktop_client layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) @@ -87,6 +94,8 @@ class ProjectExplorer(QWidget): self.tree.doubleClicked.connect(self._on_item_double_clicked) layout.addWidget(self.tree) + # MARK: - Path Helpers + def _project_root(self) -> str: return os.path.abspath(self.model.rootPath()) @@ -108,33 +117,18 @@ class ProjectExplorer(QWidget): ) return False - def _trash_directory(self) -> str: - base_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) - if not base_dir: - base_dir = os.path.join(os.path.expanduser("~"), ".lyricflow") - trash_dir = os.path.join(base_dir, "explorer_trash") - os.makedirs(trash_dir, exist_ok=True) - return trash_dir - - def _move_to_trash(self, path: str) -> str: - trash_dir = self._trash_directory() - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - name = os.path.basename(path) - target = os.path.join(trash_dir, f"{timestamp}-{name}") - counter = 1 - while os.path.exists(target): - target = os.path.join(trash_dir, f"{timestamp}-{counter}-{name}") - counter += 1 - shutil.move(path, target) - return target + # MARK: - Context Menu Actions def _show_context_menu(self, position): index = self.tree.indexAt(position) menu = QMenu() - + + new_file_act = menu.addAction("New File") + new_folder_act = menu.addAction("New Folder") rename_act = None delete_act = None if index.isValid(): + menu.addSeparator() rename_act = menu.addAction("Rename") delete_act = menu.addAction("Delete") @@ -168,11 +162,9 @@ class ProjectExplorer(QWidget): new_path = os.path.join(path, name) if not self._ensure_within_project(new_path): return - try: - with open(new_path, 'w', encoding='utf-8') as f: - pass - except Exception as e: - QMessageBox.critical(self, "Error", f"Could not create file: {e}") + result = self.client.create_entry(new_path, is_directory=False) + if not result.success: + QMessageBox.critical(self, "Error", f"Could not create file: {result.message}") def _new_folder(self, index): path = self._resolve_parent_directory(index) @@ -191,10 +183,9 @@ class ProjectExplorer(QWidget): new_path = os.path.join(path, name) if not self._ensure_within_project(new_path): return - try: - os.makedirs(new_path, exist_ok=True) - except Exception as e: - QMessageBox.critical(self, "Error", f"Could not create folder: {e}") + result = self.client.create_entry(new_path, is_directory=True) + if not result.success: + QMessageBox.critical(self, "Error", f"Could not create folder: {result.message}") def _rename_item(self, index): old_path = self.model.filePath(index) @@ -214,10 +205,9 @@ class ProjectExplorer(QWidget): new_path = os.path.join(os.path.dirname(old_path), new_name) if not self._ensure_within_project(new_path): return - try: - os.rename(old_path, new_path) - except Exception as e: - QMessageBox.critical(self, "Error", f"Rename failed: {e}") + result = self.client.rename_entry(old_path, new_path, self._project_root()) + if not result.success: + QMessageBox.critical(self, "Error", f"Rename failed: {result.message}") def _delete_item(self, index): path = self.model.filePath(index) @@ -239,10 +229,11 @@ class ProjectExplorer(QWidget): ) if confirm == QMessageBox.StandardButton.Yes: - try: - self._move_to_trash(path) - except Exception as e: - QMessageBox.critical(self, "Error", f"Delete failed: {e}") + result = self.client.delete_entry(path, root) + if not result.success: + QMessageBox.critical(self, "Error", f"Delete failed: {result.message}") + + # MARK: - Tree Interactions def _on_item_double_clicked(self, index: QModelIndex): if not self.model.isDir(index): diff --git a/src/gui/components/history_dialog.py b/src/gui/components/history_dialog.py index 8aa62c6..35a0412 100644 --- a/src/gui/components/history_dialog.py +++ b/src/gui/components/history_dialog.py @@ -1,4 +1,3 @@ -from typing import Optional from datetime import datetime from PyQt6.QtWidgets import ( QDialog, @@ -15,14 +14,19 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import Qt, pyqtSignal from src.gui.theme import Theme -from src.lyricflow_core.storage.db_manager import DatabaseManager, Snapshot +from src.gui.backend_client import DesktopBackendClient, Snapshot + + +# MARK: - History Dialog class HistoryDialog(QDialog): restore_requested = pyqtSignal(Snapshot) - def __init__(self, db_manager: DatabaseManager, file_path: str, parent=None): + # MARK: - Lifecycle + + def __init__(self, client: DesktopBackendClient, file_path: str, parent=None): super().__init__(parent) - self.db_manager = db_manager + self.client = client self.file_path = file_path self.setWindowTitle("Version History") @@ -31,6 +35,8 @@ class HistoryDialog(QDialog): self._setup_ui() self._load_snapshots() + # MARK: - Dialog Setup + def _setup_ui(self): layout = QVBoxLayout(self) @@ -89,8 +95,10 @@ class HistoryDialog(QDialog): self.splitter.setSizes([250, 550]) layout.addWidget(self.splitter) + # MARK: - Snapshot Loading + def _load_snapshots(self): - self.snapshots = self.db_manager.get_snapshots(self.file_path) + self.snapshots = self.client.get_snapshots(self.file_path) self.list_widget.clear() if not self.snapshots: @@ -104,6 +112,8 @@ class HistoryDialog(QDialog): item.setData(Qt.ItemDataRole.UserRole, snap) self.list_widget.addItem(item) + # MARK: - Selection And Restore + def _on_selection_changed(self): selected = self.list_widget.selectedItems() if not selected: diff --git a/src/gui/components/preferences_dialog.py b/src/gui/components/preferences_dialog.py index 375d16f..144710b 100644 --- a/src/gui/components/preferences_dialog.py +++ b/src/gui/components/preferences_dialog.py @@ -12,7 +12,8 @@ from PyQt6.QtWidgets import ( QVBoxLayout, ) -from src.lyricflow_core.storage.app_settings import AppPreferences +from src.gui.backend_client import AppCorePreferences +from src.gui.ui_settings import PreferencesModel, UiPreferences from src.gui.theme import Theme @@ -24,7 +25,7 @@ class PreferencesDialog(QDialog): self.setWindowTitle("Preferences") self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND};") self.setMinimumWidth(420) - self._prefs = AppPreferences() + self._prefs = PreferencesModel(AppCorePreferences(), UiPreferences()) root = QVBoxLayout(self) @@ -72,20 +73,25 @@ class PreferencesDialog(QDialog): button_box.rejected.connect(self.reject) root.addWidget(button_box) - def set_values(self, prefs: AppPreferences) -> None: + def set_values(self, prefs: PreferencesModel) -> None: self._prefs = prefs - self.reopen_last_project_cb.setChecked(prefs.reopen_last_project) - self.restore_unsaved_cb.setChecked(prefs.restore_unsaved_tabs) - self.word_wrap_default_cb.setChecked(prefs.word_wrap_default) - self.show_left_sidebar_cb.setChecked(prefs.show_left_sidebar) - self.show_right_sidebar_cb.setChecked(prefs.show_right_sidebar) + self.reopen_last_project_cb.setChecked(prefs.core.reopen_last_project) + self.restore_unsaved_cb.setChecked(prefs.core.restore_unsaved_tabs) + self.word_wrap_default_cb.setChecked(prefs.ui.word_wrap_default) + self.show_left_sidebar_cb.setChecked(prefs.ui.show_left_sidebar) + self.show_right_sidebar_cb.setChecked(prefs.ui.show_right_sidebar) - def values(self) -> AppPreferences: - return replace( - self._prefs, - reopen_last_project=self.reopen_last_project_cb.isChecked(), - restore_unsaved_tabs=self.restore_unsaved_cb.isChecked(), - word_wrap_default=self.word_wrap_default_cb.isChecked(), - show_left_sidebar=self.show_left_sidebar_cb.isChecked(), - show_right_sidebar=self.show_right_sidebar_cb.isChecked(), + def values(self) -> PreferencesModel: + return PreferencesModel( + core=replace( + self._prefs.core, + reopen_last_project=self.reopen_last_project_cb.isChecked(), + restore_unsaved_tabs=self.restore_unsaved_cb.isChecked(), + ), + ui=replace( + self._prefs.ui, + word_wrap_default=self.word_wrap_default_cb.isChecked(), + show_left_sidebar=self.show_left_sidebar_cb.isChecked(), + show_right_sidebar=self.show_right_sidebar_cb.isChecked(), + ), ) diff --git a/src/gui/components/scratchpad.py b/src/gui/components/scratchpad.py index 01b4cd1..fc153ef 100644 --- a/src/gui/components/scratchpad.py +++ b/src/gui/components/scratchpad.py @@ -2,7 +2,6 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QLabel, - QPlainTextEdit, ) from PyQt6.QtCore import pyqtSignal from src.gui.theme import Theme diff --git a/src/gui/components/sidebar.py b/src/gui/components/sidebar.py index d551be3..3429c12 100644 --- a/src/gui/components/sidebar.py +++ b/src/gui/components/sidebar.py @@ -1,18 +1,19 @@ from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QListWidget, QProgressBar, QFrame, - QScrollArea, QApplication, QMenu, ) from PyQt6.QtCore import Qt, pyqtSlot, QPoint -from src.lyricflow_core.api.analysis import analysis_service +from src.gui.backend_client import desktop_client from src.gui.theme import Theme + +# MARK: - Density Widgets + class DensityBar(QProgressBar): def __init__(self, parent=None): super().__init__(parent) @@ -30,7 +31,12 @@ class DensityBar(QProgressBar): }} """) + +# MARK: - Sidebar Widget + class Sidebar(QFrame): + # MARK: - Lifecycle + def __init__(self, parent=None): super().__init__(parent) self.setFixedWidth(300) @@ -79,6 +85,10 @@ class Sidebar(QFrame): self._enable_copy_context_menu(self.vibe_list) layout.addWidget(self.vibe_list) + # TODO(analysis): add a near-rhyme sensitivity control here once the backend accepts a slant-threshold parameter. + + # MARK: - Context Menus + def _enable_copy_context_menu(self, list_widget: QListWidget) -> None: list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) list_widget.customContextMenuRequested.connect( @@ -106,20 +116,22 @@ class Sidebar(QFrame): if chosen == copy_action: QApplication.clipboard().setText(value) + # MARK: - Sidebar Updates + @pyqtSlot(str) def on_word_selected(self, word): if not word: return self.word_label.setText(word.upper()) # Get phonemes - phones = analysis_service.phonemes(word) + phones = desktop_client.phonemes(word) if phones: self.phonetic_label.setText(" ".join(phones[0])) else: self.phonetic_label.setText("No phonetic data") # Get rhyming suggestions - suggestions = analysis_service.suggestions(word) or {} + suggestions = desktop_client.suggestions(word) or {} self.perfect_list.clear() self.perfect_list.addItems(suggestions.get("perfect", [])) @@ -127,7 +139,7 @@ class Sidebar(QFrame): self.slant_list.addItems(suggestions.get("slant", [])) # Get synonyms and vibe - results = analysis_service.synonyms(word) or {} + results = desktop_client.synonyms(word) or {} self.synonym_list.clear() self.synonym_list.addItems(results.get("synonyms", [])) @@ -136,5 +148,5 @@ class Sidebar(QFrame): @pyqtSlot(str) def update_density(self, text): - # This acts as the debounced analysis results callback + # TODO(analysis): render a density map instead of a placeholder once the UI model is defined in docs/lyricflow.md. pass diff --git a/src/gui/lyricdown.py b/src/gui/lyricdown.py new file mode 100644 index 0000000..b134356 --- /dev/null +++ b/src/gui/lyricdown.py @@ -0,0 +1,4 @@ +import re + + +TAG_PATTERN = re.compile(r"\[[^\]\n]+\]") diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 2a849e4..2cad75c 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -22,32 +22,28 @@ from .components.preferences_dialog import PreferencesDialog from .components.sidebar import Sidebar from .components.scratchpad import ScratchpadWidget from .components.history_dialog import HistoryDialog +from .backend_client import AppCorePreferences, ProjectState, SessionTabSnapshot, Snapshot, desktop_client from .theme import Theme -from src.lyricflow_core.api.project_state import ProjectState, project_state_service -from src.lyricflow_core.storage.app_settings import AppPreferences, AppSettingsStore -from src.lyricflow_core.storage.file_manager import FileManager -from src.lyricflow_core.storage.session_store import SessionStore, SessionTabSnapshot -from src.lyricflow_core.storage.db_manager import DatabaseManager +from .ui_settings import PreferencesModel, UiPreferences, UiSettingsStore ConflictResolution = Literal["snapshot", "disk", "skip"] class MainWindow(QMainWindow): + # MARK: - Lifecycle + def __init__(self): super().__init__() - self.file_manager = FileManager() + self.client = desktop_client self.editors: dict[str, LyricEditor] = {} self.current_project_root: str | None = None self._left_sidebar_width = 250 self._right_sidebar_width = 250 self.word_wrap_enabled = False - self.app_settings = AppSettingsStore() - self.session_store = SessionStore() - self.preferences: AppPreferences = self.app_settings.load() - - self.db_manager: DatabaseManager | None = None - self._setup_db_manager() + self.ui_settings = UiSettingsStore() + self.core_preferences: AppCorePreferences = self.client.load_core_preferences() + self.ui_preferences: UiPreferences = self.ui_settings.load() self.setWindowTitle("LyricFlow IDE") self.resize(1300, 850) @@ -62,7 +58,7 @@ class MainWindow(QMainWindow): self.splitter.setHandleWidth(1) self.splitter.setStyleSheet(f"QSplitter::handle {{ background-color: {Theme.CURRENT_LINE}; }}") - self.explorer = ProjectExplorer() + self.explorer = ProjectExplorer(self.client) self.explorer.setMinimumWidth(200) self.explorer.fileSelected.connect(self.open_file_path) @@ -119,12 +115,7 @@ class MainWindow(QMainWindow): self._session_autosave_timer.timeout.connect(self._save_session_snapshots) self._session_autosave_timer.start() - def _setup_db_manager(self): - if self.current_project_root: - db_path = os.path.join(self.current_project_root, ".lyricflow.db") - else: - db_path = os.path.join(os.path.expanduser("~"), ".lyricflow", "global.db") - self.db_manager = DatabaseManager(db_path) + # MARK: - Menu Setup def _create_menu_bar(self): menu_bar = self.menuBar() @@ -259,6 +250,8 @@ class MainWindow(QMainWindow): preferences_action.triggered.connect(self.open_preferences) settings_menu.addAction(preferences_action) + # MARK: - Editor Helpers + def current_editor(self) -> LyricEditor | None: widget = self.tabs.currentWidget() return widget if isinstance(widget, LyricEditor) else None @@ -350,6 +343,8 @@ class MainWindow(QMainWindow): self.tabs.setCurrentIndex(original_index) return True + # MARK: - Editor Creation + def new_file(self): editor = LyricEditor() self._setup_editor(editor) @@ -380,6 +375,8 @@ class MainWindow(QMainWindow): if editor: editor.zoom_out() + # MARK: - Layout Controls + def set_left_sidebar_visible(self, visible: bool): sizes = self.splitter.sizes() if len(sizes) < 3: @@ -444,10 +441,11 @@ class MainWindow(QMainWindow): sizes[2] = 0 self.splitter.setSizes(sizes) + # MARK: - Scratchpad And History + def _save_scratchpad(self, content: str): - if self.db_manager: - project_id = self.current_project_root or "global" - self.db_manager.save_scratchpad(project_id, content) + project_id = self.current_project_root or "global" + self.client.save_scratchpad(project_id, content) def show_history_dialog(self): editor = self.current_editor() @@ -456,11 +454,11 @@ class MainWindow(QMainWindow): return current_path = self._path_for_editor(editor) - if not current_path or not self.db_manager: + if not current_path: QMessageBox.information(self, "History", "File has no history context.") return - dialog = HistoryDialog(self.db_manager, current_path, self) + dialog = HistoryDialog(self.client, current_path, self) def on_restore(snap: Snapshot): editor.setPlainText(snap.content) @@ -470,17 +468,19 @@ class MainWindow(QMainWindow): dialog.restore_requested.connect(on_restore) dialog.exec() + # MARK: - File And Project Operations + def open_file_path(self, path: str): path = os.path.abspath(path) if path in self.editors: self.tabs.setCurrentWidget(self.editors[path]) return - content, msg = self.file_manager.load_file(path) - if content is not None: + result = self.client.read_file(path) + if result.success and result.content is not None: editor = LyricEditor() self._setup_editor(editor) - editor.setPlainText(content) + editor.setPlainText(result.content) editor.document().setModified(False) self.editors[path] = editor idx = self.tabs.addTab(editor, os.path.basename(path)) @@ -488,7 +488,7 @@ class MainWindow(QMainWindow): self.tabs.setTabToolTip(idx, path) self.statusBar().showMessage(f"Opened {path}") else: - QMessageBox.critical(self, "Error", msg) + QMessageBox.critical(self, "Error", result.message) def open_file(self): path, _ = QFileDialog.getOpenFileName( @@ -500,7 +500,7 @@ class MainWindow(QMainWindow): if path: if path.endswith(".lyricproject"): loaded = self.load_project(path) - if loaded and self.preferences.restore_unsaved_tabs: + if loaded and self.core_preferences.restore_unsaved_tabs: self._restore_session_snapshots() else: self.open_file_path(path) @@ -511,7 +511,7 @@ class MainWindow(QMainWindow): ) if path: loaded = self.load_project(path) - if loaded and self.preferences.restore_unsaved_tabs: + if loaded and self.core_preferences.restore_unsaved_tabs: self._restore_session_snapshots() def open_folder(self): @@ -521,18 +521,16 @@ class MainWindow(QMainWindow): return self.current_project_root = os.path.abspath(folder) self.explorer.set_root_path(self.current_project_root) - self.preferences.last_project_file = os.path.join( + self.core_preferences.last_project_file = os.path.join( self.current_project_root, ".lyricproject" ) self.statusBar().showMessage(f"Project: {self.current_project_root}") - self._setup_db_manager() - project_file = os.path.join(self.current_project_root, ".lyricproject") if os.path.exists(project_file): self._load_project_data(project_file) - if self.preferences.restore_unsaved_tabs: + if self.core_preferences.restore_unsaved_tabs: self._restore_session_snapshots() self._save_preferences() @@ -562,18 +560,16 @@ class MainWindow(QMainWindow): self.tabs.setTabText(idx, os.path.basename(current_path)) self.tabs.setTabToolTip(idx, current_path) - self.file_manager.current_file = current_path - success, msg = self.file_manager.save_file(editor.toPlainText()) - if success: + result = self.client.write_file(current_path, editor.toPlainText()) + if result.success: editor.document().setModified(False) - self.statusBar().showMessage(msg) - if self.db_manager: - self.db_manager.save_snapshot(current_path, editor.toPlainText()) + self.statusBar().showMessage(result.message) + self.client.save_snapshot(current_path, editor.toPlainText()) self._save_session_snapshots() if self.current_project_root: self.save_project() else: - QMessageBox.critical(self, "Error", msg) + QMessageBox.critical(self, "Error", result.message) def save_project(self): if not self.current_project_root: @@ -595,8 +591,8 @@ class MainWindow(QMainWindow): project_file = os.path.join(self.current_project_root, ".lyricproject") try: - project_state_service.write_project(project_file, project_state) - self.preferences.last_project_file = project_file + self.client.write_project(project_file, project_state) + self.core_preferences.last_project_file = project_file self.statusBar().showMessage(f"Project saved to {project_file}") except Exception as e: self.statusBar().showMessage(f"Failed to save project: {e}") @@ -609,20 +605,30 @@ class MainWindow(QMainWindow): @staticmethod def _extract_cursor_positions(data: dict) -> dict[str, int]: - return project_state_service.parse_cursor_positions(data.get("cursor_positions")) + raw = data.get("cursor_positions") + if not isinstance(raw, dict): + return {} + + parsed: dict[str, int] = {} + for path, position in raw.items(): + if not isinstance(path, str): + continue + try: + parsed[path] = max(0, int(position)) + except (TypeError, ValueError): + continue + return parsed def _load_project_data(self, project_file: str): project_file = os.path.abspath(project_file) try: self.current_project_root = os.path.dirname(project_file) - self.preferences.last_project_file = project_file + self.core_preferences.last_project_file = project_file self.explorer.set_root_path(self.current_project_root) - project_state = project_state_service.read_project(project_file) + project_state = self.client.read_project(project_file) - cursor_positions = project_state.cursor_positions - for path in project_state.open_files: - if not isinstance(path, str): - continue + cursor_positions = project_state.cursor_positions or {} + for path in project_state.open_files or []: abs_path = os.path.abspath(path) if os.path.exists(abs_path): self.open_file_path(abs_path) @@ -635,10 +641,8 @@ class MainWindow(QMainWindow): if abs_active in self.editors: self.tabs.setCurrentWidget(self.editors[abs_active]) - if self.db_manager: - project_id = self.current_project_root or "global" - content = self.db_manager.get_scratchpad(project_id) - self.scratchpad.set_content(content) + project_id = self.current_project_root or "global" + self.scratchpad.set_content(self.client.get_scratchpad(project_id)) self.scratchpad_action.setChecked(project_state.scratchpad_open) self.set_scratchpad_visible(project_state.scratchpad_open) @@ -662,12 +666,13 @@ class MainWindow(QMainWindow): self.tabs.clear() self.editors.clear() self.current_project_root = None - self._setup_db_manager() self.explorer.set_root_path(os.getcwd()) self.setWindowTitle("LyricFlow IDE") self.statusBar().showMessage("Project Closed") return True + # MARK: - Tab Actions + def close_tab(self, index: int): widget = self.tabs.widget(index) if isinstance(widget, LyricEditor): @@ -720,6 +725,8 @@ class MainWindow(QMainWindow): cursor.insertText("\n" + text) editor.setTextCursor(cursor) + # MARK: - Preferences And Session State + def toggle_word_wrap(self, enabled: bool): self.word_wrap_enabled = enabled for editor in self._iter_open_editors(): @@ -727,66 +734,69 @@ class MainWindow(QMainWindow): def open_preferences(self): dialog = PreferencesDialog(self) - dialog.set_values(self.preferences) + dialog.set_values(PreferencesModel(self.core_preferences, self.ui_preferences)) dialog.clearRequested.connect(self.clear_recovered_session_data) if dialog.exec(): - self.preferences = dialog.values() + updated = dialog.values() + self.core_preferences = updated.core + self.ui_preferences = updated.ui self._apply_preferences_to_ui() self._save_preferences() self.statusBar().showMessage("Preferences updated") def clear_recovered_session_data(self, scope: str): if scope == "workspace": - self.session_store.save(self._current_workspace_root(), []) + self.client.clear_session(self._current_workspace_root()) self.statusBar().showMessage("Recovered session data cleared for current workspace") elif scope == "all": - self.session_store.clear() + self.client.clear_session() self.statusBar().showMessage("Recovered session data cleared for all workspaces") def _apply_preferences_to_ui(self): - if self.preferences.window_geometry: - self.restoreGeometry(QByteArray(self.preferences.window_geometry)) + if self.ui_preferences.window_geometry: + self.restoreGeometry(QByteArray(self.ui_preferences.window_geometry)) - if self.preferences.splitter_sizes and len(self.preferences.splitter_sizes) == 3: - self.splitter.setSizes([int(v) for v in self.preferences.splitter_sizes]) + if self.ui_preferences.splitter_sizes and len(self.ui_preferences.splitter_sizes) == 4: + self.splitter.setSizes([int(v) for v in self.ui_preferences.splitter_sizes]) self.word_wrap_action.blockSignals(True) - self.word_wrap_action.setChecked(bool(self.preferences.word_wrap_default)) + self.word_wrap_action.setChecked(bool(self.ui_preferences.word_wrap_default)) self.word_wrap_action.blockSignals(False) - self.toggle_word_wrap(bool(self.preferences.word_wrap_default)) + self.toggle_word_wrap(bool(self.ui_preferences.word_wrap_default)) self.left_sidebar_action.blockSignals(True) - self.left_sidebar_action.setChecked(bool(self.preferences.show_left_sidebar)) + self.left_sidebar_action.setChecked(bool(self.ui_preferences.show_left_sidebar)) self.left_sidebar_action.blockSignals(False) - self.set_left_sidebar_visible(bool(self.preferences.show_left_sidebar)) + self.set_left_sidebar_visible(bool(self.ui_preferences.show_left_sidebar)) self.right_sidebar_action.blockSignals(True) - self.right_sidebar_action.setChecked(bool(self.preferences.show_right_sidebar)) + self.right_sidebar_action.setChecked(bool(self.ui_preferences.show_right_sidebar)) self.right_sidebar_action.blockSignals(False) - self.set_right_sidebar_visible(bool(self.preferences.show_right_sidebar)) + self.set_right_sidebar_visible(bool(self.ui_preferences.show_right_sidebar)) def _restore_startup_state(self): if ( - self.preferences.reopen_last_project - and self.preferences.last_project_file - and os.path.exists(self.preferences.last_project_file) + self.core_preferences.reopen_last_project + and self.core_preferences.last_project_file + and os.path.exists(self.core_preferences.last_project_file) ): - self.load_project(self.preferences.last_project_file) + self.load_project(self.core_preferences.last_project_file) - if self.preferences.restore_unsaved_tabs: + if self.core_preferences.restore_unsaved_tabs: self._restore_session_snapshots() def _save_preferences(self): - self.preferences.word_wrap_default = bool(self.word_wrap_enabled) - self.preferences.show_left_sidebar = bool(self.left_sidebar_action.isChecked()) - self.preferences.show_right_sidebar = bool(self.right_sidebar_action.isChecked()) - self.preferences.window_geometry = bytes(self.saveGeometry()) - self.preferences.splitter_sizes = self.splitter.sizes() + self.ui_preferences.word_wrap_default = bool(self.word_wrap_enabled) + self.ui_preferences.show_left_sidebar = bool(self.left_sidebar_action.isChecked()) + self.ui_preferences.show_right_sidebar = bool(self.right_sidebar_action.isChecked()) + self.ui_preferences.window_geometry = bytes(self.saveGeometry()) + self.ui_preferences.splitter_sizes = self.splitter.sizes() if self.current_project_root: - self.preferences.last_project_file = os.path.join( + self.core_preferences.last_project_file = os.path.join( self.current_project_root, ".lyricproject" ) - self.app_settings.save(self.preferences) + self.ui_settings.save(self.ui_preferences) + self.client.save_core_preferences(self.core_preferences) def _current_workspace_root(self) -> str | None: if self.current_project_root: @@ -840,13 +850,13 @@ class MainWindow(QMainWindow): return snapshots def _save_session_snapshots(self): - if not self.preferences.restore_unsaved_tabs: + if not self.core_preferences.restore_unsaved_tabs: return snapshots = self._collect_session_snapshots() - self.session_store.save(self._current_workspace_root(), snapshots) + self.client.save_session(self._current_workspace_root(), snapshots) def _restore_session_snapshots(self): - snapshots = self.session_store.load(self._current_workspace_root()) + snapshots = self.client.load_session(self._current_workspace_root()) if not snapshots: return @@ -939,6 +949,8 @@ class MainWindow(QMainWindow): cursor.setPosition(clamped) editor.setTextCursor(cursor) + # MARK: - Window Lifecycle + def closeEvent(self, event: QCloseEvent): if not self._confirm_unsaved_changes_for_scope("exiting"): event.ignore() diff --git a/src/gui/ui_settings.py b/src/gui/ui_settings.py new file mode 100644 index 0000000..c3fb15a --- /dev/null +++ b/src/gui/ui_settings.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from PyQt6.QtCore import QByteArray, QSettings + +from .backend_client import AppCorePreferences + + +@dataclass +class UiPreferences: + word_wrap_default: bool = False + show_left_sidebar: bool = True + show_right_sidebar: bool = True + window_geometry: bytes | None = None + splitter_sizes: list[int] | None = None + + +@dataclass +class PreferencesModel: + core: AppCorePreferences + ui: UiPreferences + + +class UiSettingsStore: + def __init__(self, settings: QSettings | None = None): + self._settings = settings or QSettings() + + def load(self) -> UiPreferences: + return UiPreferences( + word_wrap_default=self._settings.value("editor/word_wrap_default", False, type=bool), + show_left_sidebar=self._settings.value("appearance/show_left_sidebar", True, type=bool), + show_right_sidebar=self._settings.value("appearance/show_right_sidebar", True, type=bool), + window_geometry=self._load_window_geometry(), + splitter_sizes=self._load_splitter_sizes(), + ) + + def save(self, prefs: UiPreferences) -> None: + self._settings.setValue("editor/word_wrap_default", bool(prefs.word_wrap_default)) + self._settings.setValue("appearance/show_left_sidebar", bool(prefs.show_left_sidebar)) + self._settings.setValue("appearance/show_right_sidebar", bool(prefs.show_right_sidebar)) + + if prefs.window_geometry is None: + self._settings.remove("ui/window_geometry") + else: + self._settings.setValue("ui/window_geometry", QByteArray(prefs.window_geometry)) + + if prefs.splitter_sizes and len(prefs.splitter_sizes) == 4: + self._settings.setValue("ui/splitter_sizes", [int(value) for value in prefs.splitter_sizes]) + else: + self._settings.remove("ui/splitter_sizes") + + self._settings.sync() + + def _load_window_geometry(self) -> bytes | None: + value = self._settings.value("ui/window_geometry") + if isinstance(value, QByteArray): + return bytes(value) + if isinstance(value, (bytes, bytearray)): + return bytes(value) + return None + + def _load_splitter_sizes(self) -> list[int] | None: + value = self._settings.value("ui/splitter_sizes") + if not isinstance(value, list): + return None + + parsed: list[int] = [] + for item in value: + try: + parsed.append(int(item)) + except (TypeError, ValueError): + return None + + return parsed if len(parsed) == 4 else None diff --git a/src/lyricflow_core/__init__.py b/src/lyricflow_core/__init__.py index 50ec884..ae68325 100644 --- a/src/lyricflow_core/__init__.py +++ b/src/lyricflow_core/__init__.py @@ -1,4 +1,8 @@ -"""LyricFlow shared core logic package.""" +"""LyricFlow shared compatibility package. + +Active desktop runtime behavior should be accessed through the GUI backend +client and the bundled C# backend. Exports here remain for compatibility. +""" from .api import ( LyricAnalysisService, @@ -10,34 +14,15 @@ from .api import ( project_state_service, ) from .engine.phonetics import PhoneticProcessor, processor -from .engine.rhyme_engine import RhymeEngine, engine -from .engine.spellcheck import SpellcheckEngine, spellcheck -from .storage.app_settings import AppPreferences, AppSettingsStore -from .storage.file_manager import FileManager -from .storage.session_store import ( - GLOBAL_WORKSPACE_KEY, - SessionStore, - SessionTabSnapshot, -) __all__ = [ - "AppPreferences", - "AppSettingsStore", - "FileManager", - "GLOBAL_WORKSPACE_KEY", "LyricAnalysisService", "LyricFlowCoreFacade", "PhoneticProcessor", "ProjectState", "ProjectStateService", - "RhymeEngine", - "SessionStore", - "SessionTabSnapshot", - "SpellcheckEngine", "analysis_service", "core_api", - "engine", "project_state_service", "processor", - "spellcheck", ] diff --git a/src/lyricflow_core/api/analysis.py b/src/lyricflow_core/api/analysis.py index 8f4004e..b763105 100644 --- a/src/lyricflow_core/api/analysis.py +++ b/src/lyricflow_core/api/analysis.py @@ -1,10 +1,22 @@ -from src.lyricflow_core.engine.phonetics import PhoneticProcessor, processor -from src.lyricflow_core.engine.rhyme_engine import RhymeEngine, engine -from src.lyricflow_core.engine.spellcheck import SpellcheckEngine, spellcheck +from __future__ import annotations + +from typing import TYPE_CHECKING + +from src.lyricflow_core.api.backend_bridge import BackendBridge + +if TYPE_CHECKING: + from src.lyricflow_core.engine.phonetics import PhoneticProcessor + from src.lyricflow_core.engine.rhyme_engine import RhymeEngine + from src.lyricflow_core.engine.spellcheck import SpellcheckEngine class LyricAnalysisService: - """Stable analysis API for desktop and mobile clients.""" + """Stable analysis facade. + + Runtime analysis should come from the C# backend. Local Python engines remain + as compatibility fallbacks for tests and non-GUI callers that do not launch + the backend. + """ def __init__( self, @@ -12,44 +24,87 @@ class LyricAnalysisService: phonetic_processor: PhoneticProcessor | None = None, spellcheck_engine: SpellcheckEngine | None = None, ): - self._engine = rhyme_engine or engine - self._processor = phonetic_processor or processor - self._spellcheck = spellcheck_engine or spellcheck + self._engine_override = rhyme_engine + self._processor_override = phonetic_processor + self._spellcheck_override = spellcheck_engine + self.bridge = BackendBridge() + + @property + def _engine(self): + if self._engine_override is None: + from src.lyricflow_core.engine.rhyme_engine import engine + + self._engine_override = engine + return self._engine_override + + @property + def _processor(self): + if self._processor_override is None: + from src.lyricflow_core.engine.phonetics import processor + + self._processor_override = processor + return self._processor_override + + @property + def _spellcheck(self): + if self._spellcheck_override is None: + from src.lyricflow_core.engine.spellcheck import spellcheck + + self._spellcheck_override = spellcheck + return self._spellcheck_override def normalize_word(self, word: str) -> str: + if self.bridge.is_alive(): + return self.bridge.normalize_word(word) return self._processor.normalize_word(word) def phonemes(self, word: str) -> tuple[tuple[str, ...], ...]: + if self.bridge.is_alive(): + return self.bridge.get_phonemes(word) return self._processor.get_phonemes(word) def count_syllables(self, word: str) -> int: return self._engine.count_syllables(word) def rhyme_groups(self, text: str) -> list[dict]: + if self.bridge.is_alive(): + return self.bridge.get_rhyme_groups(text) return self._engine.get_rhyme_groups(text) def line_densities(self, text: str) -> list[float]: return self._engine.get_line_densities(text) def suggestions(self, word: str, limit: int = 20) -> dict[str, list[str]]: + if self.bridge.is_alive(): + return self.bridge.get_rhymes(word, limit=limit) return self._engine.find_suggestions(word, limit=limit) def synonyms(self, word: str, limit: int = 15) -> dict[str, list[str]]: + if self.bridge.is_alive(): + return self.bridge.get_synonyms(word, limit=limit) return self._engine.find_synonyms(word, limit=limit) def similarity(self, word1: str, word2: str) -> float: return self._engine.calculate_similarity(word1, word2) def is_known_word(self, word: str) -> bool: + if self.bridge.is_alive(): + return self.bridge.is_known_word(word) return self._spellcheck.is_known_word(word) def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]: + if self.bridge.is_alive(): + return self.bridge.get_spelling_suggestions(word, limit=limit) return self._spellcheck.spelling_suggestions(word, limit=limit) def autocorrect_candidate(self, word: str) -> str | None: + if self.bridge.is_alive(): + return self.bridge.get_autocorrect_candidate(word) return self._spellcheck.autocorrect_candidate(word) def spelling_issues(self, text: str, suggestion_limit: int = 6) -> list[dict]: + if self.bridge.is_alive(): + return self.bridge.get_text_spelling_issues(text, limit=suggestion_limit) return self._spellcheck.text_spelling_issues(text, suggestion_limit=suggestion_limit) diff --git a/src/lyricflow_core/api/backend_bridge.py b/src/lyricflow_core/api/backend_bridge.py new file mode 100644 index 0000000..26f17fa --- /dev/null +++ b/src/lyricflow_core/api/backend_bridge.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import re +from typing import Any, List, Optional +from urllib import error, parse, request + + +class BackendBridge: + def __init__(self, base_url: str = "http://127.0.0.1:5000"): + self.base_url = base_url.rstrip("/") + + def is_alive(self) -> bool: + try: + self._request_json("/api/health", timeout=0.5) + return True + except RuntimeError: + return False + + def health(self) -> dict[str, Any]: + return self._get_json("/api/health", default={"status": "offline", "ready": False}) + + def normalize_word(self, word: str) -> str: + normalized = re.sub(r"[^a-z']", "", word.lower().strip()) + if normalized.endswith("in'"): + normalized = normalized[:-1] + "g" + return normalized + + def save_snapshot(self, file_path: str, content: str) -> bool: + payload = self._post_json("/api/history/snapshots", {"file_path": file_path, "content": content}, default={}) + return payload == {} or bool(payload.get("success", True)) + + def get_snapshots(self, file_path: str) -> List[dict]: + return self._get_json("/api/history/snapshots", params={"filePath": file_path}, default=[]) + + def save_scratchpad(self, project_id: str, content: str) -> bool: + payload = self._post_json("/api/scratchpad", {"project_id": project_id, "content": content}, default={}) + return payload == {} or bool(payload.get("success", True)) + + def get_scratchpad(self, project_id: str) -> Optional[str]: + payload = self._get_json(f"/api/scratchpad/{project_id}", default={}) + content = payload.get("content") + return content if isinstance(content, str) else None + + def get_phonemes(self, word: str) -> tuple[tuple[str, ...], ...]: + payload = self._get_json("/api/analysis/phonemes", params={"word": word}, default=[]) + return tuple(tuple(item) for item in payload if isinstance(item, list)) + + def get_rhymes(self, word: str, limit: int = 20) -> dict: + return self._get_json( + "/api/analysis/suggestions", + params={"word": word, "limit": limit}, + default={"perfect": [], "slant": []}, + ) + + def get_synonyms(self, word: str, limit: int = 15) -> dict: + return self._get_json( + "/api/analysis/synonyms", + params={"word": word, "limit": limit}, + default={"synonyms": [], "vibe": []}, + ) + + def get_rhyme_groups(self, text: str) -> List[dict]: + return self._post_json("/api/analysis/rhyme-groups", {"text": text}, default=[]) + + def get_syllables(self, word: str) -> int: + payload = self._get_json("/api/analysis/syllables", params={"word": word}, default={"count": 0}) + return int(payload.get("count", 0)) + + def get_line_densities(self, text: str) -> List[float]: + payload = self._post_json("/api/analysis/line-densities", {"text": text}, default=[]) + return [float(value) for value in payload] + + def is_known_word(self, word: str) -> bool: + payload = self._get_json("/api/spellcheck/known", params={"word": word}, default={}) + return bool(payload.get("is_known", payload.get("isKnown", False))) + + def get_spelling_suggestions(self, word: str, limit: int = 6) -> List[str]: + payload = self._get_json( + "/api/spellcheck/suggestions", + params={"word": word, "limit": limit}, + default=[], + ) + return [str(value) for value in payload] + + def get_autocorrect_candidate(self, word: str) -> Optional[str]: + payload = self._get_json("/api/spellcheck/autocorrect", params={"word": word}, default={}) + candidate = payload.get("candidate") + return candidate if isinstance(candidate, str) and candidate else None + + def get_text_spelling_issues(self, text: str, limit: int = 6) -> List[dict]: + return self._post_json( + "/api/spellcheck/issues", + {"text": text}, + params={"limit": limit}, + default=[], + ) + + def read_project(self, project_file: str) -> dict[str, Any]: + return self._post_json("/api/projects/read", {"project_file": project_file}, default={}) + + def write_project(self, project_file: str, state: dict[str, Any]) -> bool: + payload = self._post_json( + "/api/projects/write", + {"project_file": project_file, "state": state}, + default={}, + ) + return payload == {} or bool(payload.get("success", True)) + + def load_session(self, workspace_root: str | None) -> List[dict[str, Any]]: + return self._post_json("/api/session/load", {"workspace_root": workspace_root}, default=[]) + + def save_session(self, workspace_root: str | None, snapshots: list[dict[str, Any]]) -> bool: + payload = self._post_json( + "/api/session/save", + {"workspace_root": workspace_root, "snapshots": snapshots}, + default={}, + ) + return payload == {} or bool(payload.get("success", True)) + + def clear_session(self, workspace_root: str | None = None) -> bool: + payload = self._post_json("/api/session/clear", {"workspace_root": workspace_root}, default={}) + return payload == {} or bool(payload.get("success", True)) + + def load_core_preferences(self) -> dict[str, Any]: + return self._get_json("/api/settings", default={}) + + def save_core_preferences(self, preferences: dict[str, Any]) -> bool: + payload = self._post_json("/api/settings", preferences, default={}) + return payload == {} or bool(payload.get("success", True)) + + def read_file(self, path: str) -> dict[str, Any]: + return self._post_json("/api/files/read", {"path": path}, default={}) + + def write_file(self, path: str, content: str) -> dict[str, Any]: + return self._post_json("/api/files/write", {"path": path, "content": content}, default={}) + + def create_entry(self, path: str, is_directory: bool = False) -> dict[str, Any]: + return self._post_json( + "/api/files/create", + {"path": path, "is_directory": is_directory}, + default={}, + ) + + def rename_entry(self, old_path: str, new_path: str, root_path: str | None) -> dict[str, Any]: + return self._post_json( + "/api/files/rename", + {"old_path": old_path, "new_path": new_path, "root_path": root_path}, + default={}, + ) + + def delete_entry(self, path: str, root_path: str | None) -> dict[str, Any]: + return self._post_json( + "/api/files/delete", + {"path": path, "root_path": root_path}, + default={}, + ) + + def _get_json(self, path: str, params: dict[str, Any] | None = None, default: Any = None) -> Any: + try: + return self._request_json(path, params=params, timeout=5.0, default=default) + except RuntimeError: + return default + + def _post_json( + self, + path: str, + payload: dict[str, Any], + params: dict[str, Any] | None = None, + default: Any = None, + ) -> Any: + try: + return self._request_json( + path, + method="POST", + payload=payload, + params=params, + timeout=10.0, + default=default, + ) + except RuntimeError: + return default + + def _request_json( + self, + path: str, + method: str = "GET", + payload: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + timeout: float = 5.0, + default: Any = None, + ) -> Any: + query = f"?{parse.urlencode(params, doseq=True)}" if params else "" + body = None + headers: dict[str, str] = {} + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = request.Request(f"{self.base_url}{path}{query}", data=body, headers=headers, method=method) + try: + with request.urlopen(req, timeout=timeout) as response: + content = response.read() + except (error.HTTPError, error.URLError, TimeoutError, OSError) as exc: + raise RuntimeError("Backend request failed") from exc + + if not content: + return default + + try: + return json.loads(content.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Backend returned invalid JSON") from exc diff --git a/src/lyricflow_core/api/project_state.py b/src/lyricflow_core/api/project_state.py index 02e5516..ee07196 100644 --- a/src/lyricflow_core/api/project_state.py +++ b/src/lyricflow_core/api/project_state.py @@ -1,8 +1,12 @@ +from __future__ import annotations + from dataclasses import dataclass, field import json import os from typing import Any +from src.lyricflow_core.api.backend_bridge import BackendBridge + @dataclass class ProjectState: @@ -15,7 +19,10 @@ class ProjectState: class ProjectStateService: - """Stable project file API for desktop and mobile clients.""" + """Stable project file facade backed by the C# service when available.""" + + def __init__(self): + self._bridge = BackendBridge() def parse_cursor_positions(self, raw: Any) -> dict[str, int]: if not isinstance(raw, dict): @@ -76,6 +83,13 @@ class ProjectStateService: } def read_project(self, project_file: str) -> ProjectState: + if self._bridge.is_alive(): + payload = self._bridge.read_project(project_file) + if not isinstance(payload, dict): + payload = {} + fallback_name = os.path.basename(os.path.dirname(os.path.abspath(project_file))) + return self.from_dict(payload, fallback_name=fallback_name) + project_file = os.path.abspath(project_file) with open(project_file, "r", encoding="utf-8") as f: payload = json.load(f) @@ -85,6 +99,10 @@ class ProjectStateService: return self.from_dict(payload, fallback_name=fallback_name) def write_project(self, project_file: str, state: ProjectState) -> None: + if self._bridge.is_alive(): + self._bridge.write_project(project_file, self.to_dict(state)) + return + project_file = os.path.abspath(project_file) payload = self.to_dict(state) with open(project_file, "w", encoding="utf-8") as f: @@ -92,4 +110,3 @@ class ProjectStateService: project_state_service = ProjectStateService() - diff --git a/src/lyricflow_core/engine/rhyme_engine.py b/src/lyricflow_core/engine/rhyme_engine.py index 4f20590..1af4bb8 100644 --- a/src/lyricflow_core/engine/rhyme_engine.py +++ b/src/lyricflow_core/engine/rhyme_engine.py @@ -1,3 +1,9 @@ +"""Compatibility fallback for rhyme analysis. + +The authoritative implementation now lives in the C# backend. This module +remains only for compatibility paths and tests that do not launch the backend. +""" + import re from functools import lru_cache from typing import Dict, List @@ -8,10 +14,12 @@ from .phonetics import processor from .syntax import TAG_PATTERN from .common import is_wordnet_available +from src.lyricflow_core.api.backend_bridge import BackendBridge class RhymeEngine: def __init__(self, threshold: float = 0.5): self.threshold = threshold + self.bridge = BackendBridge() self._perfect_index: Dict[tuple[str, ...], set[str]] = {} self._slant_index: Dict[str, set[str]] = {} self._is_indexed = False @@ -48,6 +56,9 @@ class RhymeEngine: @lru_cache(maxsize=8192) def count_syllables(self, word: str) -> int: """Counts syllables in a word using phonetic data if available.""" + if self.bridge.is_alive(): + return self.bridge.get_syllables(word) + phones = processor.get_phonemes(word) if phones: return sum(1 for p in phones[0] if any(char.isdigit() for char in p)) @@ -138,6 +149,9 @@ class RhymeEngine: def find_suggestions(self, word: str, limit: int = 20) -> Dict[str, List[str]]: """Returns perfect and slant rhymes for a given word.""" + if self.bridge.is_alive(): + return self.bridge.get_rhymes(word) + self._ensure_indexed() word = processor.normalize_word(word) phones_list = processor.get_phonemes(word) @@ -171,6 +185,11 @@ class RhymeEngine: """Analyzes text to find rhyme groups, respecting LyricDown syntax.""" if text == self._last_group_text: return list(self._last_group_results) + + # We don't have a direct Batch Analyze group endpoint yet in bridge + # that returns the exact word mapping format, so we fall back or keep local. + # But for density we can use the backend. + lines = text.split("\n") @@ -255,6 +274,13 @@ class RhymeEngine: if text == self._last_density_text: return list(self._last_density_results) + if self.bridge.is_alive(): + results = self.bridge.get_line_densities(text) + if results: + self._last_density_text = text + self._last_density_results = list(results) + return results + lines = text.split("\n") if not lines: return [] diff --git a/src/lyricflow_core/engine/spellcheck.py b/src/lyricflow_core/engine/spellcheck.py index 30cf67d..b911d68 100644 --- a/src/lyricflow_core/engine/spellcheck.py +++ b/src/lyricflow_core/engine/spellcheck.py @@ -1,3 +1,9 @@ +"""Compatibility fallback for spellcheck. + +The authoritative implementation now lives in the C# backend. This module +remains only for compatibility paths and tests that do not launch the backend. +""" + import difflib import re from functools import lru_cache @@ -13,10 +19,14 @@ from .common import is_wordnet_available class SpellcheckEngine: """Dictionary-backed spell checking for lyrics text.""" + # MARK: - Lifecycle + def __init__(self, phonetic_processor: PhoneticProcessor | None = None): self._processor = phonetic_processor or processor self._cmu_by_initial: dict[str, list[str]] | None = None + # MARK: - Dictionary Index + def _build_cmu_index(self) -> dict[str, list[str]]: by_initial: dict[str, list[str]] = {} for word in self._processor.dict.keys(): @@ -42,6 +52,8 @@ class SpellcheckEngine: return True return False + # MARK: - Suggestions + def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]: normalized = self._processor.normalize_word(word) if not normalized or self.is_known_word(normalized): @@ -53,10 +65,33 @@ class SpellcheckEngine: if not length_filtered: length_filtered = candidates - suggestions = difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.75) - if suggestions: - return suggestions - return difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.65) + scored: list[tuple[tuple[int, int, float, int, int, int, str], str]] = [] + for candidate in length_filtered: + distance = self._damerau_levenshtein_distance(normalized, candidate) + total_len = len(normalized) + len(candidate) + ratio = (total_len - distance) / total_len if total_len else 1.0 + if ratio < 0.75: + continue + + score = ( + self._heuristic_rank(normalized, candidate), + distance, + -self._sequence_similarity(normalized, candidate), + abs(len(candidate) - len(normalized)), + -self._shared_prefix_length(normalized, candidate), + -self._shared_suffix_length(normalized, candidate), + candidate, + ) + scored.append((score, candidate)) + + if not scored: + fallback = difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.65) + return fallback + + scored.sort(key=lambda item: item[0]) + return [candidate for _, candidate in scored[:limit]] + + # MARK: - Similarity Helpers @staticmethod def _levenshtein_distance(a: str, b: str) -> int: @@ -78,6 +113,120 @@ class SpellcheckEngine: prev_row = row return prev_row[-1] + @staticmethod + def _damerau_levenshtein_distance(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + + da = {ch: 0 for ch in set(a + b)} + max_distance = len(a) + len(b) + d = [[0] * (len(b) + 2) for _ in range(len(a) + 2)] + d[0][0] = max_distance + + for i in range(len(a) + 1): + d[i + 1][0] = max_distance + d[i + 1][1] = i + for j in range(len(b) + 1): + d[0][j + 1] = max_distance + d[1][j + 1] = j + + for i in range(1, len(a) + 1): + db = 0 + for j in range(1, len(b) + 1): + i1 = da[b[j - 1]] + j1 = db + cost = 1 + if a[i - 1] == b[j - 1]: + cost = 0 + db = j + + d[i + 1][j + 1] = min( + d[i][j] + cost, + d[i + 1][j] + 1, + d[i][j + 1] + 1, + d[i1][j1] + (i - i1 - 1) + 1 + (j - j1 - 1), + ) + da[a[i - 1]] = i + + return d[len(a) + 1][len(b) + 1] + + @staticmethod + def _shared_prefix_length(a: str, b: str) -> int: + count = 0 + for ca, cb in zip(a, b): + if ca != cb: + break + count += 1 + return count + + @staticmethod + def _shared_suffix_length(a: str, b: str) -> int: + count = 0 + for ca, cb in zip(reversed(a), reversed(b)): + if ca != cb: + break + count += 1 + return count + + @classmethod + def _sequence_similarity(cls, a: str, b: str) -> float: + total_len = len(a) + len(b) + if total_len == 0: + return 1.0 + lcs = cls._longest_common_subsequence_length(a, b) + return (2 * lcs) / total_len + + @staticmethod + def _longest_common_subsequence_length(a: str, b: str) -> int: + dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)] + for i, ca in enumerate(a, start=1): + for j, cb in enumerate(b, start=1): + if ca == cb: + dp[i][j] = dp[i - 1][j - 1] + 1 + else: + dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + return dp[-1][-1] + + @classmethod + def _heuristic_rank(cls, source: str, candidate: str) -> int: + if cls._is_adjacent_transposition(source, candidate): + return 0 + if cls._is_repeated_letter_expansion(source, candidate): + return 1 + return 2 + + @staticmethod + def _is_adjacent_transposition(source: str, candidate: str) -> bool: + if len(source) != len(candidate): + return False + for index in range(len(source) - 1): + if source[index] == candidate[index]: + continue + return ( + source[:index] == candidate[:index] + and source[index] == candidate[index + 1] + and source[index + 1] == candidate[index] + and source[index + 2 :] == candidate[index + 2 :] + ) + return False + + @staticmethod + def _is_repeated_letter_expansion(source: str, candidate: str) -> bool: + if len(candidate) != len(source) + 1: + return False + for index in range(len(candidate) - 1): + if candidate[index] != candidate[index + 1]: + continue + if candidate[:index] + candidate[index + 1 :] == source: + return True + return False + + # MARK: - Autocorrect + def autocorrect_candidate( self, word: str, diff --git a/src/lyricflow_core/storage/__init__.py b/src/lyricflow_core/storage/__init__.py index 0c5d689..bd60bb6 100644 --- a/src/lyricflow_core/storage/__init__.py +++ b/src/lyricflow_core/storage/__init__.py @@ -1,12 +1,7 @@ -from .app_settings import AppPreferences, AppSettingsStore -from .file_manager import FileManager -from .session_store import GLOBAL_WORKSPACE_KEY, SessionStore, SessionTabSnapshot +"""Compatibility storage surface. -__all__ = [ - "AppPreferences", - "AppSettingsStore", - "FileManager", - "GLOBAL_WORKSPACE_KEY", - "SessionStore", - "SessionTabSnapshot", -] +Active desktop runtime persistence should go through the C# backend and +src.gui.ui_settings for Qt-owned UI state. +""" + +__all__: list[str] = [] diff --git a/src/lyricflow_core/storage/app_settings.py b/src/lyricflow_core/storage/app_settings.py index f7841bd..9fa42fe 100644 --- a/src/lyricflow_core/storage/app_settings.py +++ b/src/lyricflow_core/storage/app_settings.py @@ -1,3 +1,10 @@ +"""Compatibility settings store. + +Active runtime settings are split between the C# core preferences service and +the PyQt-specific UiSettingsStore in src.gui.ui_settings. This module remains +for compatibility tests and legacy imports. +""" + from dataclasses import dataclass from typing import Optional @@ -48,7 +55,7 @@ class AppSettingsStore: else: self._settings.setValue("ui/window_geometry", QByteArray(prefs.window_geometry)) - if prefs.splitter_sizes and len(prefs.splitter_sizes) == 3: + if prefs.splitter_sizes and len(prefs.splitter_sizes) == 4: self._settings.setValue("ui/splitter_sizes", [int(v) for v in prefs.splitter_sizes]) else: self._settings.remove("ui/splitter_sizes") @@ -75,6 +82,6 @@ class AppSettingsStore: except (TypeError, ValueError): return None - if len(parsed) != 3: + if len(parsed) != 4: return None return parsed diff --git a/src/lyricflow_core/storage/db_manager.py b/src/lyricflow_core/storage/db_manager.py index 8a123b1..c191e2c 100644 --- a/src/lyricflow_core/storage/db_manager.py +++ b/src/lyricflow_core/storage/db_manager.py @@ -1,9 +1,17 @@ +"""Compatibility database adapter. + +Active runtime snapshot and scratchpad persistence should go through the C# +backend. Local SQLite support remains for compatibility and test coverage. +""" + import sqlite3 import os import time -from typing import List, Dict, Optional +from typing import List, Optional from dataclasses import dataclass +from src.lyricflow_core.api.backend_bridge import BackendBridge + @dataclass class Snapshot: id: int @@ -12,10 +20,13 @@ class Snapshot: timestamp: float class DatabaseManager: - """Manages SQLite database for project history and scratchpads to avoid file clutter.""" + """Manages SQLite database for project history and scratchpads. + Delegates to C# backend if available, otherwise falls back to local SQLite. + """ def __init__(self, db_path: str): self.db_path = db_path + self.bridge = BackendBridge() self._init_db() def _get_connection(self) -> sqlite3.Connection: @@ -23,6 +34,12 @@ class DatabaseManager: conn.row_factory = sqlite3.Row return conn + def _get_active_bridge(self) -> Optional[BackendBridge]: + """Returns the bridge if the C# backend is alive.""" + if self.bridge.is_alive(): + return self.bridge + return None + def _init_db(self) -> None: """Initialize the database schema if it doesn't exist.""" # Ensure the directory exists @@ -52,6 +69,11 @@ class DatabaseManager: def save_snapshot(self, file_path: str, content: str) -> None: """Saves a new snapshot of the file content.""" + bridge = self._get_active_bridge() + if bridge: + bridge.save_snapshot(file_path, content) + return + if not file_path or not content.strip(): return @@ -73,6 +95,19 @@ class DatabaseManager: def get_snapshots(self, file_path: str) -> List[Snapshot]: """Retrieves all snapshots for a given file, ordered newest first.""" + bridge = self._get_active_bridge() + if bridge: + data = bridge.get_snapshots(file_path) + return [ + Snapshot( + d.get("id", 0), + d.get("file_path", d.get("filePath", "")), + d.get("content", ""), + d.get("timestamp", 0), + ) + for d in data + ] + with self._get_connection() as conn: cursor = conn.execute( "SELECT id, file_path, content, timestamp FROM snapshots WHERE file_path = ? ORDER BY timestamp DESC", @@ -81,7 +116,7 @@ class DatabaseManager: return [Snapshot(row['id'], row['file_path'], row['content'], row['timestamp']) for row in cursor] def get_snapshot(self, snapshot_id: int) -> Optional[Snapshot]: - """Retrieves a specific snapshot by ID.""" + """Retrieves a specific snapshot by ID. (Note: Bridge support for single get not yet implemented)""" with self._get_connection() as conn: cursor = conn.execute( "SELECT id, file_path, content, timestamp FROM snapshots WHERE id = ?", @@ -96,6 +131,11 @@ class DatabaseManager: def save_scratchpad(self, project_id: str, content: str) -> None: """Saves or updates the scratchpad content for a project.""" + bridge = self._get_active_bridge() + if bridge: + bridge.save_scratchpad(project_id, content) + return + with self._get_connection() as conn: conn.execute( """ @@ -111,6 +151,12 @@ class DatabaseManager: def get_scratchpad(self, project_id: str) -> str: """Retrieves the scratchpad content for a project. Returns empty string if none found.""" + bridge = self._get_active_bridge() + if bridge: + content = bridge.get_scratchpad(project_id) + if content is not None: + return content + with self._get_connection() as conn: cursor = conn.execute("SELECT content FROM scratchpads WHERE project_id = ?", (project_id,)) row = cursor.fetchone() diff --git a/src/lyricflow_core/storage/file_manager.py b/src/lyricflow_core/storage/file_manager.py index 836c7f9..e6facbe 100644 --- a/src/lyricflow_core/storage/file_manager.py +++ b/src/lyricflow_core/storage/file_manager.py @@ -1,3 +1,9 @@ +"""Compatibility file manager. + +Active runtime workspace operations should go through the C# backend. This +module remains for compatibility tests and legacy imports. +""" + import os class FileManager: diff --git a/src/lyricflow_core/storage/session_store.py b/src/lyricflow_core/storage/session_store.py index 569b4dd..d8efd70 100644 --- a/src/lyricflow_core/storage/session_store.py +++ b/src/lyricflow_core/storage/session_store.py @@ -1,3 +1,9 @@ +"""Compatibility session store. + +Active runtime session persistence should go through the C# backend. This +module remains for compatibility tests and legacy imports. +""" + from dataclasses import asdict, dataclass from datetime import datetime, timezone import json diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py index 5fc1cfb..3c6ba7b 100644 --- a/tests/test_app_settings.py +++ b/tests/test_app_settings.py @@ -43,7 +43,7 @@ class TestAppSettingsStore(unittest.TestCase): show_right_sidebar=True, last_project_file="C:/demo/.lyricproject", window_geometry=b"\x01\x02\x03", - splitter_sizes=[111, 777, 222], + splitter_sizes=[111, 777, 222, 333], ) store.save(original) loaded = store.load() diff --git a/tests/test_backend_analysis_parity.py b/tests/test_backend_analysis_parity.py new file mode 100644 index 0000000..339aa6a --- /dev/null +++ b/tests/test_backend_analysis_parity.py @@ -0,0 +1,51 @@ +import unittest + +from src.gui.backend_runner import BackendRunner +from src.lyricflow_core.api.backend_bridge import BackendBridge +from src.lyricflow_core.engine.rhyme_engine import RhymeEngine +from src.lyricflow_core.engine.spellcheck import SpellcheckEngine + + +class _DeadBridge: + def is_alive(self) -> bool: + return False + + +class TestBackendAnalysisParity(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.runner = BackendRunner() + cls.runner.start() + cls.bridge = BackendBridge() + + @classmethod + def tearDownClass(cls): + cls.runner.stop() + + def test_rhyme_groups_and_density_match_python(self): + text = "# Header\n[Verse]\ncat bat\nflow glow" + local_engine = RhymeEngine() + local_engine.bridge = _DeadBridge() + + self.assertEqual(local_engine.get_rhyme_groups(text), self.bridge.get_rhyme_groups(text)) + self.assertEqual(local_engine.get_line_densities(text), self.bridge.get_line_densities(text)) + + def test_rhyme_suggestions_match_python(self): + local_engine = RhymeEngine() + local_engine.bridge = _DeadBridge() + self.assertEqual(local_engine.find_suggestions("cat"), self.bridge.get_rhymes("cat")) + + def test_transposition_suggestion_exists(self): + suggestions = self.bridge.get_spelling_suggestions("wrod", 10) + self.assertIn("word", suggestions) + + def test_spellcheck_basics_match_python(self): + local_spellcheck = SpellcheckEngine() + self.assertEqual(local_spellcheck.is_known_word("combat"), self.bridge.is_known_word("combat")) + self.assertEqual(local_spellcheck.is_known_word("gumguat"), self.bridge.is_known_word("gumguat")) + backend_candidate = self.bridge.get_autocorrect_candidate("nothign") + self.assertIn(backend_candidate, local_spellcheck.spelling_suggestions("nothign", limit=6)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backend_contract.py b/tests/test_backend_contract.py new file mode 100644 index 0000000..4a58ff0 --- /dev/null +++ b/tests/test_backend_contract.py @@ -0,0 +1,101 @@ +import os +import tempfile +import unittest + +from src.gui.backend_client import AppCorePreferences, DesktopBackendClient, ProjectState, SessionTabSnapshot +from src.gui.backend_runner import BackendRunner + + +class TestBackendContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._state_root = tempfile.TemporaryDirectory() + cls._old_session_path = os.environ.get("State__SessionPath") + cls._old_prefs_path = os.environ.get("State__CorePreferencesPath") + os.environ["State__SessionPath"] = os.path.join(cls._state_root.name, "session_snapshots.json") + os.environ["State__CorePreferencesPath"] = os.path.join(cls._state_root.name, "core_preferences.json") + cls.runner = BackendRunner() + cls.runner.start() + cls.client = DesktopBackendClient() + + @classmethod + def tearDownClass(cls): + cls.runner.stop() + if cls._old_session_path is None: + os.environ.pop("State__SessionPath", None) + else: + os.environ["State__SessionPath"] = cls._old_session_path + if cls._old_prefs_path is None: + os.environ.pop("State__CorePreferencesPath", None) + else: + os.environ["State__CorePreferencesPath"] = cls._old_prefs_path + cls._state_root.cleanup() + + def test_project_round_trip(self): + with tempfile.TemporaryDirectory() as tmp: + project_file = os.path.join(tmp, ".lyricproject") + state = ProjectState( + version=2, + name="demo", + open_files=[os.path.join(tmp, "one.lmd")], + active_file=os.path.join(tmp, "one.lmd"), + cursor_positions={os.path.join(tmp, "one.lmd"): 12}, + scratchpad_open=True, + ) + self.client.write_project(project_file, state) + loaded = self.client.read_project(project_file) + self.assertEqual(state.to_payload(), loaded.to_payload()) + + def test_session_round_trip(self): + snapshot = SessionTabSnapshot( + tab_id="untitled::0", + file_path=None, + display_name="Untitled", + content="hook line", + cursor_position=4, + is_dirty=True, + is_untitled=True, + snapshot_mtime=None, + workspace_root=None, + updated_at="2026-03-13T00:00:00+00:00", + ) + self.client.save_session(None, [snapshot]) + loaded = self.client.load_session(None) + self.assertEqual(1, len(loaded)) + self.assertEqual(snapshot.display_name, loaded[0].display_name) + self.client.clear_session(None) + + def test_core_preferences_round_trip(self): + prefs = AppCorePreferences( + reopen_last_project=False, + restore_unsaved_tabs=False, + last_project_file="C:/demo/.lyricproject", + ) + self.client.save_core_preferences(prefs) + loaded = self.client.load_core_preferences() + self.assertEqual(prefs, loaded) + + def test_file_operations_round_trip(self): + with tempfile.TemporaryDirectory() as tmp: + file_path = os.path.join(tmp, "draft.lmd") + renamed_path = os.path.join(tmp, "renamed.lmd") + + created = self.client.create_entry(file_path) + self.assertTrue(created.success, created.message) + + written = self.client.write_file(file_path, "verse") + self.assertTrue(written.success, written.message) + + loaded = self.client.read_file(file_path) + self.assertTrue(loaded.success, loaded.message) + self.assertEqual("verse", loaded.content) + + renamed = self.client.rename_entry(file_path, renamed_path, tmp) + self.assertTrue(renamed.success, renamed.message) + + deleted = self.client.delete_entry(renamed_path, tmp) + self.assertTrue(deleted.success, deleted.message) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bridge_integration.py b/tests/test_bridge_integration.py new file mode 100644 index 0000000..9064e36 --- /dev/null +++ b/tests/test_bridge_integration.py @@ -0,0 +1,78 @@ +import sys +import os +import subprocess +import time +import requests + +# Add project root to sys.path so that 'from src.lyricflow_core...' works +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, project_root) + +from src.lyricflow_core.api.backend_bridge import BackendBridge + +def test_bridge(): + # 1. Start backend + backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'LyricFlow.Core.Backend', 'LyricFlow.Backend.Api')) + print(f"Starting backend from {backend_path}...") + + # Ensure any existing backend on port 5000 is cleared (optional, but good for local dev) + + proc = subprocess.Popen( + ["dotnet", "run", "--project", backend_path, "--urls", "http://localhost:5000"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + ) + + bridge = BackendBridge("http://localhost:5000") + + # Wait for backend to start + print("Waiting for backend to start...") + max_retries = 30 + success = False + for i in range(max_retries): + if bridge.is_alive(): + print(f"Backend is alive (attempt {i+1})!") + success = True + break + time.sleep(1) + + if not success: + print("Backend failed to start.") + proc.terminate() + return + + try: + # 2. Test Snippets + print("Testing snapshots...") + test_file = "test_file_bridge.lmd" + test_content = f"Hello from C# Bridge! {time.time()}" + bridge.save_snapshot(test_file, test_content) + + time.sleep(0.5) # Give it a moment to commit + snapshots = bridge.get_snapshots(test_file) + print(f"Found {len(snapshots)} snapshots.") + + if snapshots and snapshots[0].get('content') == test_content: + print("Snapshot test PASSED") + else: + print(f"Snapshot test FAILED. Expected: {test_content}, Got: {snapshots[0].get('content') if snapshots else 'None'}") + + # 3. Test Scratchpad + print("Testing scratchpad...") + project_id = "test_proj_bridge" + scratch_content = "Some ideas in C# Bridge" + bridge.save_scratchpad(project_id, scratch_content) + + time.sleep(0.5) + retrieved = bridge.get_scratchpad(project_id) + if retrieved == scratch_content: + print("Scratchpad test PASSED") + else: + print(f"Scratchpad test FAILED (got: {retrieved})") + + finally: + print("Terminating backend...") + proc.terminate() + +if __name__ == "__main__": + test_bridge() diff --git a/tests/test_engine_direct.py b/tests/test_engine_direct.py new file mode 100644 index 0000000..d04823c --- /dev/null +++ b/tests/test_engine_direct.py @@ -0,0 +1,51 @@ +import sys +import os +import time +import requests + +# Add project root to sys.path +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, project_root) + +from src.lyricflow_core.engine.rhyme_engine import RhymeEngine + +def test_engine_direct(): + engine = RhymeEngine() + + print("Testing existing backend on port 5000...") + if not engine.bridge.is_alive(): + print("Backend is NOT alive!") + return + + # 2. Test Syllables + print("\nTesting syllable counting...") + words = ["rap", "lyrical", "gravitation", "sky"] + for w in words: + syl = engine.count_syllables(w) + print(f"'{w}': {syl} syllables") + + # 3. Test Rhyme Suggestions + print("\nTesting rhyme suggestions...") + test_word = "flow" + rhymes = engine.find_suggestions(test_word) + print(f"Rhymes for '{test_word}': {len(rhymes['perfect'])} perfect, {len(rhymes['slant'])} slant") + if rhymes['perfect']: + print(f"Perfect example: {rhymes['perfect'][0]}") + + # 4. Test Line Densities (Heavy processing) + print("\nTesting line density batch processing...") + test_text = """ + I'm the lyrical miracle, spiritual individual + In the physical world, it's getting critical + My rhymes are mystical, highly statistical + You're just typical, completely satirical + """ + start_time = time.time() + densities = engine.get_line_densities(test_text) + end_time = time.time() + + print(f"Line densities: {[round(d, 2) for d in densities]}") + print(f"Processing time (Batch C#): {end_time - start_time:.4f}s") + +if __name__ == "__main__": + test_engine_direct() diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py new file mode 100644 index 0000000..923ab28 --- /dev/null +++ b/tests/test_engine_integration.py @@ -0,0 +1,77 @@ +import sys +import os +import time +import subprocess + +# Add project root to sys.path +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, project_root) + +from src.lyricflow_core.engine.rhyme_engine import RhymeEngine + +def test_engine_bridge(): + # 1. Start backend + backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'LyricFlow.Core.Backend', 'LyricFlow.Backend.Api')) + print(f"Starting backend from {backend_path}...") + + proc = subprocess.Popen( + ["dotnet", "run", "--project", backend_path, "--urls", "http://localhost:5000"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + ) + + engine = RhymeEngine() + + # Wait for backend to start + print("Waiting for backend for engine tests...") + max_retries = 30 + success = False + for i in range(max_retries): + if engine.bridge.is_alive(): + print(f"Backend is alive (attempt {i+1})!") + success = True + break + time.sleep(1) + + if not success: + print("Backend failed to start.") + proc.terminate() + return + + try: + # 2. Test Syllables + print("\nTesting syllable counting...") + words = ["rap", "lyrical", "gravitation", "sky"] + for w in words: + syl = engine.count_syllables(w) + print(f"'{w}': {syl} syllables") + + # 3. Test Rhyme Suggestions + print("\nTesting rhyme suggestions...") + test_word = "flow" + rhymes = engine.find_suggestions(test_word) + print(f"Rhymes for '{test_word}': {len(rhymes['perfect'])} perfect, {len(rhymes['slant'])} slant") + if rhymes['perfect']: + print(f"Perfect example: {rhymes['perfect'][0]}") + + # 4. Test Line Densities (Heavy processing) + print("\nTesting line density batch processing...") + test_text = """ + I'm the lyrical miracle, spiritual individual + In the physical world, it's getting critical + My rhymes are mystical, highly statistical + You're just typical, completely satirical + """ + start_time = time.time() + densities = engine.get_line_densities(test_text) + end_time = time.time() + + print(f"Line densities: {[round(d, 2) for d in densities]}") + print(f"Processing time (Batch C#): {end_time - start_time:.4f}s") + + finally: + print("\nTerminating backend...") + proc.terminate() + +if __name__ == "__main__": + test_engine_bridge() diff --git a/tests/test_spellcheck.py b/tests/test_spellcheck.py index 823cff0..04bc7c4 100644 --- a/tests/test_spellcheck.py +++ b/tests/test_spellcheck.py @@ -14,6 +14,14 @@ class TestSpellcheck(unittest.TestCase): suggestions = analysis_service.spelling_suggestions("helo") self.assertIn("hello", suggestions) + def test_suggestions_handle_transposition_typo(self): + suggestions = analysis_service.spelling_suggestions("wrod") + self.assertIn("word", suggestions) + + def test_suggestions_handle_common_suffix_typo(self): + suggestions = analysis_service.spelling_suggestions("spelle") + self.assertTrue("spell" in suggestions or "spelled" in suggestions) + def test_autocorrect_candidate_for_typo(self): self.assertEqual("nothing", analysis_service.autocorrect_candidate("nothign")) diff --git a/tests/test_spellcheck_direct.py b/tests/test_spellcheck_direct.py new file mode 100644 index 0000000..601cf20 --- /dev/null +++ b/tests/test_spellcheck_direct.py @@ -0,0 +1,43 @@ +import sys +import os +import time +import requests + +# Add project root to sys.path +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, project_root) + +from src.lyricflow_core.api.analysis import analysis_service + +def test_spellcheck_direct(): + print("Testing spellcheck via analysis_service (C# backend)...") + + # 1. Test is_known_word + words = ["hello", "rap", "lyrical", "mistakeeee"] + for w in words: + known = analysis_service.is_known_word(w) + print(f"'{w}' is known: {known}") + + # 2. Test spelling suggestions + mispelled = "lyricall" + suggestions = analysis_service.spelling_suggestions(mispelled) + print(f"\nSuggestions for '{mispelled}': {suggestions}") + + # 3. Test batch spelling issues + text = """ + I'm the lyrical miracle, spriritual individual + In the physcial world, it's getting critcial + """ + print("\nTesting batch spelling issues...") + start_time = time.time() + issues = analysis_service.spelling_issues(text) + end_time = time.time() + + print(f"Found {len(issues)} issues.") + for issue in issues: + print(f" Line {issue['line']}: '{issue['word']}' -> {issue['suggestions'][:3]}") + + print(f"\nProcessing time (Batch C#): {end_time - start_time:.4f}s") + +if __name__ == "__main__": + test_spellcheck_direct()