Add LyricFlow .NET backend API and Python bridge integration
- introduce `LyricFlow.Core.Backend` with shared DTOs, rhyme/spellcheck engines, and REST endpoints - wire Python GUI/core to run and call the backend via new bridge/client modules - add backend parity/integration tests and update packaging/ignore settings
This commit is contained in:
parent
ae2ba3d873
commit
e0f298ba36
14
.gitignore
vendored
14
.gitignore
vendored
@ -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
|
||||
/samples/bishpls.lmd
|
||||
/scripts
|
||||
bin/
|
||||
obj/
|
||||
AGENTS.md
|
||||
communication.lmd
|
||||
devtool.json
|
||||
untitled4.lmd
|
||||
|
||||
45
LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiContracts.cs
Normal file
45
LyricFlow.Core.Backend/LyricFlow.Backend.Api/ApiContracts.cs
Normal file
@ -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<SessionTabSnapshotDto> 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
|
||||
);
|
||||
@ -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<double>))]
|
||||
[JsonSerializable(typeof(List<List<string>>))]
|
||||
[JsonSerializable(typeof(List<RhymeGroupDto>))]
|
||||
[JsonSerializable(typeof(List<SessionTabSnapshotDto>))]
|
||||
[JsonSerializable(typeof(List<SnapshotDto>))]
|
||||
[JsonSerializable(typeof(List<SpellingIssueDto>))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[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
|
||||
{
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LyricFlow.Core\LyricFlow.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
294
LyricFlow.Core.Backend/LyricFlow.Backend.Api/Program.cs
Normal file
294
LyricFlow.Core.Backend/LyricFlow.Backend.Api/Program.cs
Normal file
@ -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<WordNetLexicon>()));
|
||||
builder.Services.AddSingleton(sp => new SpellcheckEngine(processor, sp.GetRequiredService<WordNetLexicon>()));
|
||||
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<double> CalculateLineDensities(RhymeEngine engine, string text)
|
||||
{
|
||||
var lines = text.Split('\n');
|
||||
var groups = engine.GetRhymeGroups(text);
|
||||
using var groupIter = groups.GetEnumerator();
|
||||
|
||||
var densities = new List<double>();
|
||||
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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": "*"
|
||||
}
|
||||
BIN
LyricFlow.Core.Backend/LyricFlow.Backend.Api/data/lyricflow.db
Normal file
BIN
LyricFlow.Core.Backend/LyricFlow.Backend.Api/data/lyricflow.db
Normal file
Binary file not shown.
2
LyricFlow.Core.Backend/LyricFlow.Core.slnx
Normal file
2
LyricFlow.Core.Backend/LyricFlow.Core.slnx
Normal file
@ -0,0 +1,2 @@
|
||||
<Solution>
|
||||
</Solution>
|
||||
6
LyricFlow.Core.Backend/LyricFlow.Core/Class1.cs
Normal file
6
LyricFlow.Core.Backend/LyricFlow.Core/Class1.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace LyricFlow.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
117
LyricFlow.Core.Backend/LyricFlow.Core/Dtos/CoreDtos.cs
Normal file
117
LyricFlow.Core.Backend/LyricFlow.Core/Dtos/CoreDtos.cs
Normal file
@ -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<string> OpenFiles,
|
||||
[property: JsonPropertyName("active_file")] string? ActiveFile,
|
||||
[property: JsonPropertyName("cursor_positions")] Dictionary<string, int> 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<string> Perfect,
|
||||
[property: JsonPropertyName("slant")] List<string> Slant
|
||||
);
|
||||
|
||||
public record SynonymResponseDto(
|
||||
[property: JsonPropertyName("synonyms")] List<string> Synonyms,
|
||||
[property: JsonPropertyName("vibe")] List<string> Vibe
|
||||
);
|
||||
|
||||
public record SpellingIssueDto(
|
||||
[property: JsonPropertyName("word")] string Word,
|
||||
[property: JsonPropertyName("normalized")] string Normalized,
|
||||
[property: JsonPropertyName("line")] int Line,
|
||||
[property: JsonPropertyName("suggestions")] List<string> Suggestions
|
||||
);
|
||||
@ -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<string, List<List<string>>> _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<List<string>>();
|
||||
_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<List<string>> GetPhonemes(string word)
|
||||
{
|
||||
var normalized = NormalizeWord(word);
|
||||
if (_dictionary.TryGetValue(normalized, out var phones))
|
||||
{
|
||||
return phones;
|
||||
}
|
||||
return new List<List<string>>();
|
||||
}
|
||||
|
||||
public Dictionary<string, List<List<string>>> Dictionary => _dictionary;
|
||||
}
|
||||
281
LyricFlow.Core.Backend/LyricFlow.Core/Engine/RhymeEngine.cs
Normal file
281
LyricFlow.Core.Backend/LyricFlow.Core/Engine/RhymeEngine.cs
Normal file
@ -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<string, HashSet<string>> _perfectIndex = new();
|
||||
private readonly Dictionary<string, HashSet<string>> _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<string>();
|
||||
_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<string>();
|
||||
_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<string> first, List<string> 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<string>();
|
||||
var slant = new HashSet<string>();
|
||||
|
||||
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<string[]> GetWordSuffixes(string word)
|
||||
{
|
||||
return _processor.GetPhonemes(word)
|
||||
.Select(phones => phones.Skip(Math.Max(0, phones.Count - 2)).ToArray())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<RhymeGroupDto> 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<string, int?>();
|
||||
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<string[]> first, List<string[]> second)
|
||||
{
|
||||
foreach (var f in first)
|
||||
{
|
||||
foreach (var s in second)
|
||||
{
|
||||
if (f.SequenceEqual(s)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
455
LyricFlow.Core.Backend/LyricFlow.Core/Engine/SpellcheckEngine.cs
Normal file
455
LyricFlow.Core.Backend/LyricFlow.Core/Engine/SpellcheckEngine.cs
Normal file
@ -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<char, List<string>>? _cmuByInitial;
|
||||
|
||||
// MARK: - Lifecycle
|
||||
#region Lifecycle
|
||||
|
||||
public SpellcheckEngine(PhoneticProcessor processor, WordNetLexicon wordNet)
|
||||
{
|
||||
_processor = processor;
|
||||
_wordNet = wordNet;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// MARK: - Dictionary Index
|
||||
#region Dictionary Index
|
||||
|
||||
private Dictionary<char, List<string>> CmuWordsByInitial
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cmuByInitial == null)
|
||||
{
|
||||
_cmuByInitial = new Dictionary<char, List<string>>();
|
||||
foreach (var word in _processor.Dictionary.Keys)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word)) continue;
|
||||
char initial = word[0];
|
||||
if (!_cmuByInitial.ContainsKey(initial)) _cmuByInitial[initial] = new List<string>();
|
||||
_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<string> GetSpellingSuggestions(string word, int limit = 6)
|
||||
{
|
||||
var normalized = _processor.NormalizeWord(word);
|
||||
if (string.IsNullOrEmpty(normalized) || IsKnownWord(normalized)) return new List<string>();
|
||||
|
||||
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<string> GetCloseMatches(string word, List<string> 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<char, int>();
|
||||
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<SpellingIssueDto> GetTextSpellingIssues(string text, int suggestionLimit = 6)
|
||||
{
|
||||
var issues = new List<SpellingIssueDto>();
|
||||
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
|
||||
}
|
||||
12
LyricFlow.Core.Backend/LyricFlow.Core/LyricFlow.Core.csproj
Normal file
12
LyricFlow.Core.Backend/LyricFlow.Core/LyricFlow.Core.csproj
Normal file
@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -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
|
||||
{
|
||||
}
|
||||
24
LyricFlow.Core.Backend/LyricFlow.Core/Services/AppPaths.cs
Normal file
24
LyricFlow.Core.Backend/LyricFlow.Core/Services/AppPaths.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
193
LyricFlow.Core.Backend/LyricFlow.Core/Services/FileService.cs
Normal file
193
LyricFlow.Core.Backend/LyricFlow.Core/Services/FileService.cs
Normal file
@ -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
|
||||
}
|
||||
@ -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<string> EnumerateCandidates(string? configuredPath, string envVar, string[] resourceSegments)
|
||||
{
|
||||
var roots = new List<string>();
|
||||
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<string> EnumerateZipCandidates(string? configuredPath, string envVar)
|
||||
{
|
||||
var roots = new List<string>();
|
||||
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
|
||||
}
|
||||
@ -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<string, int> ReadCursorPositions(JsonElement payload)
|
||||
{
|
||||
var cursorPositions = new Dictionary<string, int>();
|
||||
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<string> ReadStringList(JsonElement payload, string key)
|
||||
{
|
||||
var values = new List<string>();
|
||||
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
|
||||
}
|
||||
@ -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<SessionTabSnapshotDto> Load(string? workspaceRoot)
|
||||
{
|
||||
var payload = ReadPayload();
|
||||
return payload.Workspaces.TryGetValue(WorkspaceKey(workspaceRoot), out var snapshots) ? snapshots : [];
|
||||
}
|
||||
|
||||
public void Save(string? workspaceRoot, List<SessionTabSnapshotDto> 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<string, List<SessionTabSnapshotDto>> Workspaces { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
298
LyricFlow.Core.Backend/LyricFlow.Core/Services/WordNetLexicon.cs
Normal file
298
LyricFlow.Core.Backend/LyricFlow.Core/Services/WordNetLexicon.cs
Normal file
@ -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<string, List<SynsetRef>> _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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var vibe = new SortedSet<string>(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<string>(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<SynsetPointer>(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<string> 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<string> Words, List<SynsetPointer> Pointers);
|
||||
private sealed record SynsetPointer(string Symbol, long TargetOffset, char TargetPos);
|
||||
|
||||
#endregion
|
||||
}
|
||||
16
LyricFlow.Core.Backend/LyricFlow.Core/Storage/Snapshot.cs
Normal file
16
LyricFlow.Core.Backend/LyricFlow.Core/Storage/Snapshot.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
142
LyricFlow.Core.Backend/LyricFlow.Core/Storage/StorageService.cs
Normal file
142
LyricFlow.Core.Backend/LyricFlow.Core/Storage/StorageService.cs
Normal file
@ -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<List<Snapshot>> GetSnapshotsAsync(string filePath)
|
||||
{
|
||||
var result = new List<Snapshot>();
|
||||
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<string> 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
36
run.py
36
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()
|
||||
|
||||
423
src/gui/backend_client.py
Normal file
423
src/gui/backend_client.py
Normal file
@ -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
|
||||
153
src/gui/backend_runner.py
Normal file
153
src/gui/backend_runner.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
@ -2,7 +2,6 @@ from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
)
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from src.gui.theme import Theme
|
||||
|
||||
@ -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
|
||||
|
||||
4
src/gui/lyricdown.py
Normal file
4
src/gui/lyricdown.py
Normal file
@ -0,0 +1,4 @@
|
||||
import re
|
||||
|
||||
|
||||
TAG_PATTERN = re.compile(r"\[[^\]\n]+\]")
|
||||
@ -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()
|
||||
|
||||
75
src/gui/ui_settings.py
Normal file
75
src/gui/ui_settings.py
Normal file
@ -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
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
213
src/lyricflow_core/api/backend_bridge.py
Normal file
213
src/lyricflow_core/api/backend_bridge.py
Normal file
@ -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
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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] = []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
51
tests/test_backend_analysis_parity.py
Normal file
51
tests/test_backend_analysis_parity.py
Normal file
@ -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()
|
||||
101
tests/test_backend_contract.py
Normal file
101
tests/test_backend_contract.py
Normal file
@ -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()
|
||||
78
tests/test_bridge_integration.py
Normal file
78
tests/test_bridge_integration.py
Normal file
@ -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()
|
||||
51
tests/test_engine_direct.py
Normal file
51
tests/test_engine_direct.py
Normal file
@ -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()
|
||||
77
tests/test_engine_integration.py
Normal file
77
tests/test_engine_integration.py
Normal file
@ -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()
|
||||
@ -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"))
|
||||
|
||||
|
||||
43
tests/test_spellcheck_direct.py
Normal file
43
tests/test_spellcheck_direct.py
Normal file
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user