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
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
|
.cache
|
||||||
|
.dotnet_home
|
||||||
|
.nuget
|
||||||
|
.pip
|
||||||
|
.pydeps
|
||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@ -14,4 +19,11 @@ __pycache__
|
|||||||
/data
|
/data
|
||||||
/assets
|
/assets
|
||||||
/docs/build
|
/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')
|
sip_binary = (os.path.join(pyqt6_path, f), 'PyQt6')
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get NLTK data path from environment or default locations
|
datas = []
|
||||||
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 = [
|
backend_publish_dir = os.path.join('build', 'publish', 'backend')
|
||||||
('src', 'src'),
|
if os.path.isdir(backend_publish_dir):
|
||||||
('assets', 'assets'),
|
for root, _, files in os.walk(backend_publish_dir):
|
||||||
('data', 'data'),
|
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_path = os.path.join(nltk_path, 'corpora', 'cmudict')
|
||||||
cmudict_zip = os.path.join(nltk_path, 'corpora', 'cmudict.zip')
|
cmudict_zip = os.path.join(nltk_path, 'corpora', 'cmudict.zip')
|
||||||
wordnet_zip = os.path.join(nltk_path, 'corpora', 'wordnet.zip')
|
wordnet_zip = os.path.join(nltk_path, 'corpora', 'wordnet.zip')
|
||||||
|
added_any = False
|
||||||
if os.path.exists(cmudict_path):
|
if os.path.exists(cmudict_path):
|
||||||
datas.append((cmudict_path, 'nltk_data/corpora/cmudict'))
|
datas.append((cmudict_path, 'nltk_data/corpora/cmudict'))
|
||||||
|
added_any = True
|
||||||
if os.path.exists(cmudict_zip):
|
if os.path.exists(cmudict_zip):
|
||||||
datas.append((cmudict_zip, 'nltk_data/corpora'))
|
datas.append((cmudict_zip, 'nltk_data/corpora'))
|
||||||
|
added_any = True
|
||||||
if os.path.exists(wordnet_zip):
|
if os.path.exists(wordnet_zip):
|
||||||
datas.append((wordnet_zip, 'nltk_data/corpora'))
|
datas.append((wordnet_zip, 'nltk_data/corpora'))
|
||||||
|
added_any = True
|
||||||
|
if added_any:
|
||||||
|
break
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['run.py'],
|
['run.py'],
|
||||||
pathex=[os.path.abspath('src')],
|
pathex=[os.path.abspath('.')],
|
||||||
binaries=[sip_binary] if sip_binary else [],
|
binaries=[sip_binary] if sip_binary else [],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=['PyQt6.sip', 'nltk.corpus.wordnet', 'nltk.corpus.cmudict'],
|
hiddenimports=['PyQt6.sip', 'src.gui.main_window', 'src.gui.backend_runner'],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[],
|
excludes=[
|
||||||
|
'IPython',
|
||||||
|
'matplotlib',
|
||||||
|
'nltk',
|
||||||
|
'pygame',
|
||||||
|
'pygame_ce',
|
||||||
|
'pytest',
|
||||||
|
'requests',
|
||||||
|
'sympy',
|
||||||
|
'tkinter',
|
||||||
|
'_pytest',
|
||||||
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=block_cipher,
|
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 sys
|
||||||
import os
|
from pathlib import Path
|
||||||
import nltk
|
|
||||||
|
|
||||||
# Add bundled nltk_data path if running as executable
|
# MARK: - Import Bootstrap
|
||||||
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)
|
|
||||||
|
|
||||||
# Add src to path
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src')))
|
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.QtWidgets import QApplication
|
||||||
from PyQt6.QtCore import QCoreApplication
|
from PyQt6.QtCore import QCoreApplication
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Application Entry Point
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
QCoreApplication.setOrganizationName("LyricFlow")
|
QCoreApplication.setOrganizationName("LyricFlow")
|
||||||
QCoreApplication.setOrganizationDomain("lyricflow.local")
|
QCoreApplication.setOrganizationDomain("lyricflow.local")
|
||||||
QCoreApplication.setApplicationName("LyricFlow")
|
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 = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec())
|
|
||||||
|
exit_code = app.exec()
|
||||||
|
|
||||||
|
# Cleanup backend
|
||||||
|
backend_runner.stop()
|
||||||
|
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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.QtWidgets import QMenu, QPlainTextEdit, QWidget
|
||||||
from PyQt6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QTextCursor, QPainter
|
from PyQt6.QtGui import QAction, QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QTextCursor, QPainter
|
||||||
from PyQt6.QtCore import QTimer, pyqtSignal, QRect, Qt
|
from PyQt6.QtCore import QTimer, pyqtSignal, Qt
|
||||||
from src.lyricflow_core.api.analysis import analysis_service
|
from typing import Optional, List, Dict
|
||||||
from typing import Optional, List, Tuple, Dict
|
|
||||||
import re
|
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
|
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):
|
class RhymeHighlighter(QSyntaxHighlighter):
|
||||||
def __init__(self, parent: Optional[QWidget] = None):
|
def __init__(self, parent: Optional[QWidget] = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -71,16 +79,16 @@ class RhymeHighlighter(QSyntaxHighlighter):
|
|||||||
excluded_ranges.append((start, end))
|
excluded_ranges.append((start, end))
|
||||||
|
|
||||||
# 5. Bold (**text**)
|
# 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)
|
self.setFormat(match.start(), len(match.group()), bold_fmt)
|
||||||
|
|
||||||
# 6. Italic (*text*)
|
# 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)
|
self.setFormat(match.start(), len(match.group()), italic_fmt)
|
||||||
|
|
||||||
# 7. Highlight Rhymes
|
# 7. Highlight Rhymes
|
||||||
if self.rhyme_map:
|
if self.rhyme_map:
|
||||||
for match in re.finditer(r"\b\w+\b", text):
|
for match in WORD_PATTERN.finditer(text):
|
||||||
word = match.group()
|
word = match.group()
|
||||||
start = match.start()
|
start = match.start()
|
||||||
|
|
||||||
@ -103,17 +111,17 @@ class RhymeHighlighter(QSyntaxHighlighter):
|
|||||||
|
|
||||||
# 8. Spellcheck overlay
|
# 8. Spellcheck overlay
|
||||||
if self.spellcheck_enabled:
|
if self.spellcheck_enabled:
|
||||||
for match in re.finditer(r"\b\w+\b", text):
|
for match in WORD_PATTERN.finditer(text):
|
||||||
word = match.group()
|
word = match.group()
|
||||||
start = match.start()
|
start = match.start()
|
||||||
is_excluded = any(ex_start <= start < ex_end for ex_start, ex_end in excluded_ranges)
|
is_excluded = any(ex_start <= start < ex_end for ex_start, ex_end in excluded_ranges)
|
||||||
if is_excluded:
|
if is_excluded:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
normalized = analysis_service.normalize_word(word)
|
normalized = desktop_client.normalize_word(word)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
if analysis_service.is_known_word(normalized):
|
if desktop_client.is_known_word(normalized):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing = self.format(start)
|
existing = self.format(start)
|
||||||
@ -122,10 +130,16 @@ class RhymeHighlighter(QSyntaxHighlighter):
|
|||||||
fmt.setUnderlineColor(QColor(Theme.SPELLCHECK_ERROR))
|
fmt.setUnderlineColor(QColor(Theme.SPELLCHECK_ERROR))
|
||||||
self.setFormat(start, len(word), fmt)
|
self.setFormat(start, len(word), fmt)
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Editor Widget
|
||||||
|
|
||||||
class LyricEditor(QPlainTextEdit):
|
class LyricEditor(QPlainTextEdit):
|
||||||
textChangedDebounced = pyqtSignal(str)
|
textChangedDebounced = pyqtSignal(str)
|
||||||
wordSelected = pyqtSignal(str)
|
wordSelected = pyqtSignal(str)
|
||||||
_AUTOCORRECT_DELIMITERS = set(" \t.,!?;:)]}\"'")
|
_AUTOCORRECT_DELIMITERS = set(" \t.,!?;:)]}\"'")
|
||||||
|
_SUGGESTION_LIMIT = 5
|
||||||
|
|
||||||
|
# MARK: - Lifecycle
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None):
|
def __init__(self, parent: Optional[QWidget] = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -134,6 +148,8 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
self.autocorrect_enabled = True
|
self.autocorrect_enabled = True
|
||||||
self._last_emitted_text: Optional[str] = None
|
self._last_emitted_text: Optional[str] = None
|
||||||
self._last_analyzed_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 = QTimer()
|
||||||
self.timer.setSingleShot(True)
|
self.timer.setSingleShot(True)
|
||||||
@ -149,14 +165,20 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
self.setFont(font)
|
self.setFont(font)
|
||||||
|
|
||||||
|
# MARK: - Input Handling
|
||||||
|
|
||||||
def keyPressEvent(self, e):
|
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)
|
super().keyPressEvent(e)
|
||||||
if not self.autocorrect_enabled:
|
if not self.autocorrect_enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
typed = e.text()
|
typed = e.text()
|
||||||
if typed and typed in self._AUTOCORRECT_DELIMITERS:
|
if typed and typed in self._AUTOCORRECT_DELIMITERS:
|
||||||
self._autocorrect_previous_word()
|
self._suggest_previous_word()
|
||||||
|
|
||||||
def wheelEvent(self, e):
|
def wheelEvent(self, e):
|
||||||
if e.modifiers() == Qt.KeyboardModifier.ControlModifier:
|
if e.modifiers() == Qt.KeyboardModifier.ControlModifier:
|
||||||
@ -173,6 +195,8 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
def zoom_out(self):
|
def zoom_out(self):
|
||||||
self.zoomOut(1)
|
self.zoomOut(1)
|
||||||
|
|
||||||
|
# MARK: - Line Editing
|
||||||
|
|
||||||
def move_line_up(self):
|
def move_line_up(self):
|
||||||
cursor = self.textCursor()
|
cursor = self.textCursor()
|
||||||
if not cursor.hasSelection():
|
if not cursor.hasSelection():
|
||||||
@ -252,6 +276,8 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
if cursor.position() > end:
|
if cursor.position() > end:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# MARK: - Rendering
|
||||||
|
|
||||||
def paintEvent(self, e):
|
def paintEvent(self, e):
|
||||||
super().paintEvent(e)
|
super().paintEvent(e)
|
||||||
painter = QPainter(self.viewport())
|
painter = QPainter(self.viewport())
|
||||||
@ -261,6 +287,8 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
font = self.font()
|
font = self.font()
|
||||||
font.setPointSize(max(8, font.pointSize() - 2))
|
font.setPointSize(max(8, font.pointSize() - 2))
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
|
rect = self.viewport().rect()
|
||||||
|
ascent = self.fontMetrics().ascent()
|
||||||
|
|
||||||
block = self.firstVisibleBlock()
|
block = self.firstVisibleBlock()
|
||||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||||
@ -268,18 +296,10 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
|
|
||||||
while block.isValid() and top <= e.rect().bottom():
|
while block.isValid() and top <= e.rect().bottom():
|
||||||
if block.isVisible() and bottom >= e.rect().top():
|
if block.isVisible() and bottom >= e.rect().top():
|
||||||
text = block.text().strip()
|
block_number = block.blockNumber()
|
||||||
if text and not (text.startswith('[') and text.endswith(']')):
|
count = self._line_syllable_counts[block_number] if block_number < len(self._line_syllable_counts) else 0
|
||||||
# Count syllables for the whole line
|
if count > 0:
|
||||||
words = re.findall(r"\b\w+\b", text) if ' ' in text else [text]
|
painter.drawText(rect.width() - 40, int(top) + ascent, str(count))
|
||||||
# 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 = block.next()
|
block = block.next()
|
||||||
top = bottom
|
top = bottom
|
||||||
@ -288,7 +308,25 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
def _on_text_changed(self):
|
def _on_text_changed(self):
|
||||||
self.timer.start()
|
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()
|
cursor = self.textCursor()
|
||||||
block = cursor.block()
|
block = cursor.block()
|
||||||
block_text = block.text()
|
block_text = block.text()
|
||||||
@ -309,6 +347,18 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
if block_text[delimiter_idx] not in self._AUTOCORRECT_DELIMITERS:
|
if block_text[delimiter_idx] not in self._AUTOCORRECT_DELIMITERS:
|
||||||
return
|
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)]
|
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):
|
if any(start <= delimiter_idx < end for start, end in excluded_ranges):
|
||||||
return
|
return
|
||||||
@ -332,24 +382,96 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
if any(start <= word_start < end for start, end in excluded_ranges):
|
if any(start <= word_start < end for start, end in excluded_ranges):
|
||||||
return
|
return
|
||||||
original = block_text[word_start:word_end]
|
original = block_text[word_start:word_end]
|
||||||
suggestion = analysis_service.autocorrect_candidate(original)
|
return (block.position() + word_start, block.position() + word_end, original)
|
||||||
if not suggestion:
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
if original.isupper():
|
start_abs, end_abs, original = word_info
|
||||||
replacement = suggestion.upper()
|
suggestions = self._spelling_suggestions_for_word(original)
|
||||||
elif original[0].isupper():
|
if not suggestions:
|
||||||
replacement = suggestion.capitalize()
|
|
||||||
else:
|
|
||||||
replacement = suggestion
|
|
||||||
|
|
||||||
if replacement == original:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
start_abs = block.position() + word_start
|
self._show_suggestion_menu(start_abs, end_abs, suggestions)
|
||||||
end_abs = block.position() + word_end
|
|
||||||
|
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()
|
old_pos = cursor.position()
|
||||||
delta = len(replacement) - len(original)
|
delta = len(replacement) - (end_abs - start_abs)
|
||||||
|
|
||||||
edit_cursor = self.textCursor()
|
edit_cursor = self.textCursor()
|
||||||
edit_cursor.beginEditBlock()
|
edit_cursor.beginEditBlock()
|
||||||
@ -362,11 +484,17 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
final_cursor.setPosition(max(0, old_pos + delta))
|
final_cursor.setPosition(max(0, old_pos + delta))
|
||||||
self.setTextCursor(final_cursor)
|
self.setTextCursor(final_cursor)
|
||||||
|
|
||||||
|
def _clear_suggestion_menu(self):
|
||||||
|
self._suggestion_menu = None
|
||||||
|
|
||||||
|
# MARK: - Analysis Signals
|
||||||
|
|
||||||
def _emit_debounced(self):
|
def _emit_debounced(self):
|
||||||
text = self.toPlainText()
|
text = self.toPlainText()
|
||||||
if text == self._last_emitted_text:
|
if text == self._last_emitted_text:
|
||||||
return
|
return
|
||||||
self._last_emitted_text = text
|
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)
|
self.textChangedDebounced.emit(text)
|
||||||
|
|
||||||
def _on_cursor_moved(self):
|
def _on_cursor_moved(self):
|
||||||
@ -397,19 +525,40 @@ class LyricEditor(QPlainTextEdit):
|
|||||||
if word:
|
if word:
|
||||||
self.wordSelected.emit(word)
|
self.wordSelected.emit(word)
|
||||||
|
|
||||||
|
# MARK: - Analysis Caching
|
||||||
|
|
||||||
def _analyze(self, text: str):
|
def _analyze(self, text: str):
|
||||||
if text == self._last_analyzed_text:
|
if text == self._last_analyzed_text:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
self.highlighter.set_results([])
|
self.highlighter.set_results([])
|
||||||
|
self._line_syllable_counts = []
|
||||||
v = self.viewport()
|
v = self.viewport()
|
||||||
if v: v.update()
|
if v: v.update()
|
||||||
self._last_analyzed_text = text
|
self._last_analyzed_text = text
|
||||||
return
|
return
|
||||||
|
|
||||||
results = analysis_service.rhyme_groups(text)
|
results = desktop_client.rhyme_groups(text)
|
||||||
self.highlighter.set_results(results)
|
self.highlighter.set_results(results)
|
||||||
|
self._line_syllable_counts = self._compute_line_syllable_counts(text)
|
||||||
v = self.viewport()
|
v = self.viewport()
|
||||||
if v: v.update() # Redraw syllables
|
if v: v.update() # Redraw syllables
|
||||||
self._last_analyzed_text = text
|
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,
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTreeView, QLabel,
|
||||||
QMenu, QInputDialog, QMessageBox)
|
QMenu, QInputDialog, QMessageBox)
|
||||||
from PyQt6.QtGui import QFileSystemModel
|
from PyQt6.QtGui import QFileSystemModel
|
||||||
from PyQt6.QtCore import QDir, pyqtSignal, QModelIndex, Qt, QStandardPaths
|
from PyQt6.QtCore import QDir, pyqtSignal, QModelIndex, Qt
|
||||||
from datetime import datetime
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
|
|
||||||
|
from src.gui.backend_client import DesktopBackendClient, desktop_client
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Path Validation
|
||||||
|
|
||||||
def _is_valid_entry_name(name: str) -> bool:
|
def _is_valid_entry_name(name: str) -> bool:
|
||||||
cleaned = name.strip()
|
cleaned = name.strip()
|
||||||
@ -25,11 +27,16 @@ def _is_within_root(root_path: str, candidate_path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Explorer Widget
|
||||||
|
|
||||||
class ProjectExplorer(QWidget):
|
class ProjectExplorer(QWidget):
|
||||||
fileSelected = pyqtSignal(str)
|
fileSelected = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
# MARK: - Lifecycle
|
||||||
|
|
||||||
|
def __init__(self, client: DesktopBackendClient | None = None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.client = client or desktop_client
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
@ -87,6 +94,8 @@ class ProjectExplorer(QWidget):
|
|||||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||||
layout.addWidget(self.tree)
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
# MARK: - Path Helpers
|
||||||
|
|
||||||
def _project_root(self) -> str:
|
def _project_root(self) -> str:
|
||||||
return os.path.abspath(self.model.rootPath())
|
return os.path.abspath(self.model.rootPath())
|
||||||
|
|
||||||
@ -108,33 +117,18 @@ class ProjectExplorer(QWidget):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _trash_directory(self) -> str:
|
# MARK: - Context Menu Actions
|
||||||
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
|
|
||||||
|
|
||||||
def _show_context_menu(self, position):
|
def _show_context_menu(self, position):
|
||||||
index = self.tree.indexAt(position)
|
index = self.tree.indexAt(position)
|
||||||
menu = QMenu()
|
menu = QMenu()
|
||||||
|
|
||||||
|
new_file_act = menu.addAction("New File")
|
||||||
|
new_folder_act = menu.addAction("New Folder")
|
||||||
rename_act = None
|
rename_act = None
|
||||||
delete_act = None
|
delete_act = None
|
||||||
if index.isValid():
|
if index.isValid():
|
||||||
|
menu.addSeparator()
|
||||||
rename_act = menu.addAction("Rename")
|
rename_act = menu.addAction("Rename")
|
||||||
delete_act = menu.addAction("Delete")
|
delete_act = menu.addAction("Delete")
|
||||||
|
|
||||||
@ -168,11 +162,9 @@ class ProjectExplorer(QWidget):
|
|||||||
new_path = os.path.join(path, name)
|
new_path = os.path.join(path, name)
|
||||||
if not self._ensure_within_project(new_path):
|
if not self._ensure_within_project(new_path):
|
||||||
return
|
return
|
||||||
try:
|
result = self.client.create_entry(new_path, is_directory=False)
|
||||||
with open(new_path, 'w', encoding='utf-8') as f:
|
if not result.success:
|
||||||
pass
|
QMessageBox.critical(self, "Error", f"Could not create file: {result.message}")
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.critical(self, "Error", f"Could not create file: {e}")
|
|
||||||
|
|
||||||
def _new_folder(self, index):
|
def _new_folder(self, index):
|
||||||
path = self._resolve_parent_directory(index)
|
path = self._resolve_parent_directory(index)
|
||||||
@ -191,10 +183,9 @@ class ProjectExplorer(QWidget):
|
|||||||
new_path = os.path.join(path, name)
|
new_path = os.path.join(path, name)
|
||||||
if not self._ensure_within_project(new_path):
|
if not self._ensure_within_project(new_path):
|
||||||
return
|
return
|
||||||
try:
|
result = self.client.create_entry(new_path, is_directory=True)
|
||||||
os.makedirs(new_path, exist_ok=True)
|
if not result.success:
|
||||||
except Exception as e:
|
QMessageBox.critical(self, "Error", f"Could not create folder: {result.message}")
|
||||||
QMessageBox.critical(self, "Error", f"Could not create folder: {e}")
|
|
||||||
|
|
||||||
def _rename_item(self, index):
|
def _rename_item(self, index):
|
||||||
old_path = self.model.filePath(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)
|
new_path = os.path.join(os.path.dirname(old_path), new_name)
|
||||||
if not self._ensure_within_project(new_path):
|
if not self._ensure_within_project(new_path):
|
||||||
return
|
return
|
||||||
try:
|
result = self.client.rename_entry(old_path, new_path, self._project_root())
|
||||||
os.rename(old_path, new_path)
|
if not result.success:
|
||||||
except Exception as e:
|
QMessageBox.critical(self, "Error", f"Rename failed: {result.message}")
|
||||||
QMessageBox.critical(self, "Error", f"Rename failed: {e}")
|
|
||||||
|
|
||||||
def _delete_item(self, index):
|
def _delete_item(self, index):
|
||||||
path = self.model.filePath(index)
|
path = self.model.filePath(index)
|
||||||
@ -239,10 +229,11 @@ class ProjectExplorer(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if confirm == QMessageBox.StandardButton.Yes:
|
if confirm == QMessageBox.StandardButton.Yes:
|
||||||
try:
|
result = self.client.delete_entry(path, root)
|
||||||
self._move_to_trash(path)
|
if not result.success:
|
||||||
except Exception as e:
|
QMessageBox.critical(self, "Error", f"Delete failed: {result.message}")
|
||||||
QMessageBox.critical(self, "Error", f"Delete failed: {e}")
|
|
||||||
|
# MARK: - Tree Interactions
|
||||||
|
|
||||||
def _on_item_double_clicked(self, index: QModelIndex):
|
def _on_item_double_clicked(self, index: QModelIndex):
|
||||||
if not self.model.isDir(index):
|
if not self.model.isDir(index):
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
@ -15,14 +14,19 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
from src.gui.theme import Theme
|
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):
|
class HistoryDialog(QDialog):
|
||||||
restore_requested = pyqtSignal(Snapshot)
|
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)
|
super().__init__(parent)
|
||||||
self.db_manager = db_manager
|
self.client = client
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
|
|
||||||
self.setWindowTitle("Version History")
|
self.setWindowTitle("Version History")
|
||||||
@ -31,6 +35,8 @@ class HistoryDialog(QDialog):
|
|||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._load_snapshots()
|
self._load_snapshots()
|
||||||
|
|
||||||
|
# MARK: - Dialog Setup
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
@ -89,8 +95,10 @@ class HistoryDialog(QDialog):
|
|||||||
self.splitter.setSizes([250, 550])
|
self.splitter.setSizes([250, 550])
|
||||||
layout.addWidget(self.splitter)
|
layout.addWidget(self.splitter)
|
||||||
|
|
||||||
|
# MARK: - Snapshot Loading
|
||||||
|
|
||||||
def _load_snapshots(self):
|
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()
|
self.list_widget.clear()
|
||||||
|
|
||||||
if not self.snapshots:
|
if not self.snapshots:
|
||||||
@ -104,6 +112,8 @@ class HistoryDialog(QDialog):
|
|||||||
item.setData(Qt.ItemDataRole.UserRole, snap)
|
item.setData(Qt.ItemDataRole.UserRole, snap)
|
||||||
self.list_widget.addItem(item)
|
self.list_widget.addItem(item)
|
||||||
|
|
||||||
|
# MARK: - Selection And Restore
|
||||||
|
|
||||||
def _on_selection_changed(self):
|
def _on_selection_changed(self):
|
||||||
selected = self.list_widget.selectedItems()
|
selected = self.list_widget.selectedItems()
|
||||||
if not selected:
|
if not selected:
|
||||||
|
|||||||
@ -12,7 +12,8 @@ from PyQt6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
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
|
from src.gui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ class PreferencesDialog(QDialog):
|
|||||||
self.setWindowTitle("Preferences")
|
self.setWindowTitle("Preferences")
|
||||||
self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND};")
|
self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND};")
|
||||||
self.setMinimumWidth(420)
|
self.setMinimumWidth(420)
|
||||||
self._prefs = AppPreferences()
|
self._prefs = PreferencesModel(AppCorePreferences(), UiPreferences())
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
@ -72,20 +73,25 @@ class PreferencesDialog(QDialog):
|
|||||||
button_box.rejected.connect(self.reject)
|
button_box.rejected.connect(self.reject)
|
||||||
root.addWidget(button_box)
|
root.addWidget(button_box)
|
||||||
|
|
||||||
def set_values(self, prefs: AppPreferences) -> None:
|
def set_values(self, prefs: PreferencesModel) -> None:
|
||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
self.reopen_last_project_cb.setChecked(prefs.reopen_last_project)
|
self.reopen_last_project_cb.setChecked(prefs.core.reopen_last_project)
|
||||||
self.restore_unsaved_cb.setChecked(prefs.restore_unsaved_tabs)
|
self.restore_unsaved_cb.setChecked(prefs.core.restore_unsaved_tabs)
|
||||||
self.word_wrap_default_cb.setChecked(prefs.word_wrap_default)
|
self.word_wrap_default_cb.setChecked(prefs.ui.word_wrap_default)
|
||||||
self.show_left_sidebar_cb.setChecked(prefs.show_left_sidebar)
|
self.show_left_sidebar_cb.setChecked(prefs.ui.show_left_sidebar)
|
||||||
self.show_right_sidebar_cb.setChecked(prefs.show_right_sidebar)
|
self.show_right_sidebar_cb.setChecked(prefs.ui.show_right_sidebar)
|
||||||
|
|
||||||
def values(self) -> AppPreferences:
|
def values(self) -> PreferencesModel:
|
||||||
return replace(
|
return PreferencesModel(
|
||||||
self._prefs,
|
core=replace(
|
||||||
reopen_last_project=self.reopen_last_project_cb.isChecked(),
|
self._prefs.core,
|
||||||
restore_unsaved_tabs=self.restore_unsaved_cb.isChecked(),
|
reopen_last_project=self.reopen_last_project_cb.isChecked(),
|
||||||
word_wrap_default=self.word_wrap_default_cb.isChecked(),
|
restore_unsaved_tabs=self.restore_unsaved_cb.isChecked(),
|
||||||
show_left_sidebar=self.show_left_sidebar_cb.isChecked(),
|
),
|
||||||
show_right_sidebar=self.show_right_sidebar_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,
|
QWidget,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPlainTextEdit,
|
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import pyqtSignal
|
from PyQt6.QtCore import pyqtSignal
|
||||||
from src.gui.theme import Theme
|
from src.gui.theme import Theme
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget,
|
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
QFrame,
|
QFrame,
|
||||||
QScrollArea,
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QMenu,
|
QMenu,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSlot, QPoint
|
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
|
from src.gui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Density Widgets
|
||||||
|
|
||||||
class DensityBar(QProgressBar):
|
class DensityBar(QProgressBar):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -30,7 +31,12 @@ class DensityBar(QProgressBar):
|
|||||||
}}
|
}}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# MARK: - Sidebar Widget
|
||||||
|
|
||||||
class Sidebar(QFrame):
|
class Sidebar(QFrame):
|
||||||
|
# MARK: - Lifecycle
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setFixedWidth(300)
|
self.setFixedWidth(300)
|
||||||
@ -79,6 +85,10 @@ class Sidebar(QFrame):
|
|||||||
self._enable_copy_context_menu(self.vibe_list)
|
self._enable_copy_context_menu(self.vibe_list)
|
||||||
layout.addWidget(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:
|
def _enable_copy_context_menu(self, list_widget: QListWidget) -> None:
|
||||||
list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
list_widget.customContextMenuRequested.connect(
|
list_widget.customContextMenuRequested.connect(
|
||||||
@ -106,20 +116,22 @@ class Sidebar(QFrame):
|
|||||||
if chosen == copy_action:
|
if chosen == copy_action:
|
||||||
QApplication.clipboard().setText(value)
|
QApplication.clipboard().setText(value)
|
||||||
|
|
||||||
|
# MARK: - Sidebar Updates
|
||||||
|
|
||||||
@pyqtSlot(str)
|
@pyqtSlot(str)
|
||||||
def on_word_selected(self, word):
|
def on_word_selected(self, word):
|
||||||
if not word: return
|
if not word: return
|
||||||
self.word_label.setText(word.upper())
|
self.word_label.setText(word.upper())
|
||||||
|
|
||||||
# Get phonemes
|
# Get phonemes
|
||||||
phones = analysis_service.phonemes(word)
|
phones = desktop_client.phonemes(word)
|
||||||
if phones:
|
if phones:
|
||||||
self.phonetic_label.setText(" ".join(phones[0]))
|
self.phonetic_label.setText(" ".join(phones[0]))
|
||||||
else:
|
else:
|
||||||
self.phonetic_label.setText("No phonetic data")
|
self.phonetic_label.setText("No phonetic data")
|
||||||
|
|
||||||
# Get rhyming suggestions
|
# Get rhyming suggestions
|
||||||
suggestions = analysis_service.suggestions(word) or {}
|
suggestions = desktop_client.suggestions(word) or {}
|
||||||
self.perfect_list.clear()
|
self.perfect_list.clear()
|
||||||
self.perfect_list.addItems(suggestions.get("perfect", []))
|
self.perfect_list.addItems(suggestions.get("perfect", []))
|
||||||
|
|
||||||
@ -127,7 +139,7 @@ class Sidebar(QFrame):
|
|||||||
self.slant_list.addItems(suggestions.get("slant", []))
|
self.slant_list.addItems(suggestions.get("slant", []))
|
||||||
|
|
||||||
# Get synonyms and vibe
|
# Get synonyms and vibe
|
||||||
results = analysis_service.synonyms(word) or {}
|
results = desktop_client.synonyms(word) or {}
|
||||||
self.synonym_list.clear()
|
self.synonym_list.clear()
|
||||||
self.synonym_list.addItems(results.get("synonyms", []))
|
self.synonym_list.addItems(results.get("synonyms", []))
|
||||||
|
|
||||||
@ -136,5 +148,5 @@ class Sidebar(QFrame):
|
|||||||
|
|
||||||
@pyqtSlot(str)
|
@pyqtSlot(str)
|
||||||
def update_density(self, text):
|
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
|
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.sidebar import Sidebar
|
||||||
from .components.scratchpad import ScratchpadWidget
|
from .components.scratchpad import ScratchpadWidget
|
||||||
from .components.history_dialog import HistoryDialog
|
from .components.history_dialog import HistoryDialog
|
||||||
|
from .backend_client import AppCorePreferences, ProjectState, SessionTabSnapshot, Snapshot, desktop_client
|
||||||
from .theme import Theme
|
from .theme import Theme
|
||||||
from src.lyricflow_core.api.project_state import ProjectState, project_state_service
|
from .ui_settings import PreferencesModel, UiPreferences, UiSettingsStore
|
||||||
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
|
|
||||||
|
|
||||||
ConflictResolution = Literal["snapshot", "disk", "skip"]
|
ConflictResolution = Literal["snapshot", "disk", "skip"]
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
# MARK: - Lifecycle
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.file_manager = FileManager()
|
self.client = desktop_client
|
||||||
self.editors: dict[str, LyricEditor] = {}
|
self.editors: dict[str, LyricEditor] = {}
|
||||||
self.current_project_root: str | None = None
|
self.current_project_root: str | None = None
|
||||||
self._left_sidebar_width = 250
|
self._left_sidebar_width = 250
|
||||||
self._right_sidebar_width = 250
|
self._right_sidebar_width = 250
|
||||||
self.word_wrap_enabled = False
|
self.word_wrap_enabled = False
|
||||||
|
|
||||||
self.app_settings = AppSettingsStore()
|
self.ui_settings = UiSettingsStore()
|
||||||
self.session_store = SessionStore()
|
self.core_preferences: AppCorePreferences = self.client.load_core_preferences()
|
||||||
self.preferences: AppPreferences = self.app_settings.load()
|
self.ui_preferences: UiPreferences = self.ui_settings.load()
|
||||||
|
|
||||||
self.db_manager: DatabaseManager | None = None
|
|
||||||
self._setup_db_manager()
|
|
||||||
|
|
||||||
self.setWindowTitle("LyricFlow IDE")
|
self.setWindowTitle("LyricFlow IDE")
|
||||||
self.resize(1300, 850)
|
self.resize(1300, 850)
|
||||||
@ -62,7 +58,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.splitter.setHandleWidth(1)
|
self.splitter.setHandleWidth(1)
|
||||||
self.splitter.setStyleSheet(f"QSplitter::handle {{ background-color: {Theme.CURRENT_LINE}; }}")
|
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.setMinimumWidth(200)
|
||||||
self.explorer.fileSelected.connect(self.open_file_path)
|
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.timeout.connect(self._save_session_snapshots)
|
||||||
self._session_autosave_timer.start()
|
self._session_autosave_timer.start()
|
||||||
|
|
||||||
def _setup_db_manager(self):
|
# MARK: - Menu Setup
|
||||||
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)
|
|
||||||
|
|
||||||
def _create_menu_bar(self):
|
def _create_menu_bar(self):
|
||||||
menu_bar = self.menuBar()
|
menu_bar = self.menuBar()
|
||||||
@ -259,6 +250,8 @@ class MainWindow(QMainWindow):
|
|||||||
preferences_action.triggered.connect(self.open_preferences)
|
preferences_action.triggered.connect(self.open_preferences)
|
||||||
settings_menu.addAction(preferences_action)
|
settings_menu.addAction(preferences_action)
|
||||||
|
|
||||||
|
# MARK: - Editor Helpers
|
||||||
|
|
||||||
def current_editor(self) -> LyricEditor | None:
|
def current_editor(self) -> LyricEditor | None:
|
||||||
widget = self.tabs.currentWidget()
|
widget = self.tabs.currentWidget()
|
||||||
return widget if isinstance(widget, LyricEditor) else None
|
return widget if isinstance(widget, LyricEditor) else None
|
||||||
@ -350,6 +343,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.setCurrentIndex(original_index)
|
self.tabs.setCurrentIndex(original_index)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# MARK: - Editor Creation
|
||||||
|
|
||||||
def new_file(self):
|
def new_file(self):
|
||||||
editor = LyricEditor()
|
editor = LyricEditor()
|
||||||
self._setup_editor(editor)
|
self._setup_editor(editor)
|
||||||
@ -380,6 +375,8 @@ class MainWindow(QMainWindow):
|
|||||||
if editor:
|
if editor:
|
||||||
editor.zoom_out()
|
editor.zoom_out()
|
||||||
|
|
||||||
|
# MARK: - Layout Controls
|
||||||
|
|
||||||
def set_left_sidebar_visible(self, visible: bool):
|
def set_left_sidebar_visible(self, visible: bool):
|
||||||
sizes = self.splitter.sizes()
|
sizes = self.splitter.sizes()
|
||||||
if len(sizes) < 3:
|
if len(sizes) < 3:
|
||||||
@ -444,10 +441,11 @@ class MainWindow(QMainWindow):
|
|||||||
sizes[2] = 0
|
sizes[2] = 0
|
||||||
self.splitter.setSizes(sizes)
|
self.splitter.setSizes(sizes)
|
||||||
|
|
||||||
|
# MARK: - Scratchpad And History
|
||||||
|
|
||||||
def _save_scratchpad(self, content: str):
|
def _save_scratchpad(self, content: str):
|
||||||
if self.db_manager:
|
project_id = self.current_project_root or "global"
|
||||||
project_id = self.current_project_root or "global"
|
self.client.save_scratchpad(project_id, content)
|
||||||
self.db_manager.save_scratchpad(project_id, content)
|
|
||||||
|
|
||||||
def show_history_dialog(self):
|
def show_history_dialog(self):
|
||||||
editor = self.current_editor()
|
editor = self.current_editor()
|
||||||
@ -456,11 +454,11 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
current_path = self._path_for_editor(editor)
|
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.")
|
QMessageBox.information(self, "History", "File has no history context.")
|
||||||
return
|
return
|
||||||
|
|
||||||
dialog = HistoryDialog(self.db_manager, current_path, self)
|
dialog = HistoryDialog(self.client, current_path, self)
|
||||||
|
|
||||||
def on_restore(snap: Snapshot):
|
def on_restore(snap: Snapshot):
|
||||||
editor.setPlainText(snap.content)
|
editor.setPlainText(snap.content)
|
||||||
@ -470,17 +468,19 @@ class MainWindow(QMainWindow):
|
|||||||
dialog.restore_requested.connect(on_restore)
|
dialog.restore_requested.connect(on_restore)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
|
# MARK: - File And Project Operations
|
||||||
|
|
||||||
def open_file_path(self, path: str):
|
def open_file_path(self, path: str):
|
||||||
path = os.path.abspath(path)
|
path = os.path.abspath(path)
|
||||||
if path in self.editors:
|
if path in self.editors:
|
||||||
self.tabs.setCurrentWidget(self.editors[path])
|
self.tabs.setCurrentWidget(self.editors[path])
|
||||||
return
|
return
|
||||||
|
|
||||||
content, msg = self.file_manager.load_file(path)
|
result = self.client.read_file(path)
|
||||||
if content is not None:
|
if result.success and result.content is not None:
|
||||||
editor = LyricEditor()
|
editor = LyricEditor()
|
||||||
self._setup_editor(editor)
|
self._setup_editor(editor)
|
||||||
editor.setPlainText(content)
|
editor.setPlainText(result.content)
|
||||||
editor.document().setModified(False)
|
editor.document().setModified(False)
|
||||||
self.editors[path] = editor
|
self.editors[path] = editor
|
||||||
idx = self.tabs.addTab(editor, os.path.basename(path))
|
idx = self.tabs.addTab(editor, os.path.basename(path))
|
||||||
@ -488,7 +488,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.setTabToolTip(idx, path)
|
self.tabs.setTabToolTip(idx, path)
|
||||||
self.statusBar().showMessage(f"Opened {path}")
|
self.statusBar().showMessage(f"Opened {path}")
|
||||||
else:
|
else:
|
||||||
QMessageBox.critical(self, "Error", msg)
|
QMessageBox.critical(self, "Error", result.message)
|
||||||
|
|
||||||
def open_file(self):
|
def open_file(self):
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
@ -500,7 +500,7 @@ class MainWindow(QMainWindow):
|
|||||||
if path:
|
if path:
|
||||||
if path.endswith(".lyricproject"):
|
if path.endswith(".lyricproject"):
|
||||||
loaded = self.load_project(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()
|
self._restore_session_snapshots()
|
||||||
else:
|
else:
|
||||||
self.open_file_path(path)
|
self.open_file_path(path)
|
||||||
@ -511,7 +511,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
loaded = self.load_project(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()
|
self._restore_session_snapshots()
|
||||||
|
|
||||||
def open_folder(self):
|
def open_folder(self):
|
||||||
@ -521,18 +521,16 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self.current_project_root = os.path.abspath(folder)
|
self.current_project_root = os.path.abspath(folder)
|
||||||
self.explorer.set_root_path(self.current_project_root)
|
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.current_project_root, ".lyricproject"
|
||||||
)
|
)
|
||||||
self.statusBar().showMessage(f"Project: {self.current_project_root}")
|
self.statusBar().showMessage(f"Project: {self.current_project_root}")
|
||||||
|
|
||||||
self._setup_db_manager()
|
|
||||||
|
|
||||||
project_file = os.path.join(self.current_project_root, ".lyricproject")
|
project_file = os.path.join(self.current_project_root, ".lyricproject")
|
||||||
if os.path.exists(project_file):
|
if os.path.exists(project_file):
|
||||||
self._load_project_data(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._restore_session_snapshots()
|
||||||
|
|
||||||
self._save_preferences()
|
self._save_preferences()
|
||||||
@ -562,18 +560,16 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.setTabText(idx, os.path.basename(current_path))
|
self.tabs.setTabText(idx, os.path.basename(current_path))
|
||||||
self.tabs.setTabToolTip(idx, current_path)
|
self.tabs.setTabToolTip(idx, current_path)
|
||||||
|
|
||||||
self.file_manager.current_file = current_path
|
result = self.client.write_file(current_path, editor.toPlainText())
|
||||||
success, msg = self.file_manager.save_file(editor.toPlainText())
|
if result.success:
|
||||||
if success:
|
|
||||||
editor.document().setModified(False)
|
editor.document().setModified(False)
|
||||||
self.statusBar().showMessage(msg)
|
self.statusBar().showMessage(result.message)
|
||||||
if self.db_manager:
|
self.client.save_snapshot(current_path, editor.toPlainText())
|
||||||
self.db_manager.save_snapshot(current_path, editor.toPlainText())
|
|
||||||
self._save_session_snapshots()
|
self._save_session_snapshots()
|
||||||
if self.current_project_root:
|
if self.current_project_root:
|
||||||
self.save_project()
|
self.save_project()
|
||||||
else:
|
else:
|
||||||
QMessageBox.critical(self, "Error", msg)
|
QMessageBox.critical(self, "Error", result.message)
|
||||||
|
|
||||||
def save_project(self):
|
def save_project(self):
|
||||||
if not self.current_project_root:
|
if not self.current_project_root:
|
||||||
@ -595,8 +591,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
project_file = os.path.join(self.current_project_root, ".lyricproject")
|
project_file = os.path.join(self.current_project_root, ".lyricproject")
|
||||||
try:
|
try:
|
||||||
project_state_service.write_project(project_file, project_state)
|
self.client.write_project(project_file, project_state)
|
||||||
self.preferences.last_project_file = project_file
|
self.core_preferences.last_project_file = project_file
|
||||||
self.statusBar().showMessage(f"Project saved to {project_file}")
|
self.statusBar().showMessage(f"Project saved to {project_file}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.statusBar().showMessage(f"Failed to save project: {e}")
|
self.statusBar().showMessage(f"Failed to save project: {e}")
|
||||||
@ -609,20 +605,30 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_cursor_positions(data: dict) -> dict[str, int]:
|
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):
|
def _load_project_data(self, project_file: str):
|
||||||
project_file = os.path.abspath(project_file)
|
project_file = os.path.abspath(project_file)
|
||||||
try:
|
try:
|
||||||
self.current_project_root = os.path.dirname(project_file)
|
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)
|
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
|
cursor_positions = project_state.cursor_positions or {}
|
||||||
for path in project_state.open_files:
|
for path in project_state.open_files or []:
|
||||||
if not isinstance(path, str):
|
|
||||||
continue
|
|
||||||
abs_path = os.path.abspath(path)
|
abs_path = os.path.abspath(path)
|
||||||
if os.path.exists(abs_path):
|
if os.path.exists(abs_path):
|
||||||
self.open_file_path(abs_path)
|
self.open_file_path(abs_path)
|
||||||
@ -635,10 +641,8 @@ class MainWindow(QMainWindow):
|
|||||||
if abs_active in self.editors:
|
if abs_active in self.editors:
|
||||||
self.tabs.setCurrentWidget(self.editors[abs_active])
|
self.tabs.setCurrentWidget(self.editors[abs_active])
|
||||||
|
|
||||||
if self.db_manager:
|
project_id = self.current_project_root or "global"
|
||||||
project_id = self.current_project_root or "global"
|
self.scratchpad.set_content(self.client.get_scratchpad(project_id))
|
||||||
content = self.db_manager.get_scratchpad(project_id)
|
|
||||||
self.scratchpad.set_content(content)
|
|
||||||
|
|
||||||
self.scratchpad_action.setChecked(project_state.scratchpad_open)
|
self.scratchpad_action.setChecked(project_state.scratchpad_open)
|
||||||
self.set_scratchpad_visible(project_state.scratchpad_open)
|
self.set_scratchpad_visible(project_state.scratchpad_open)
|
||||||
@ -662,12 +666,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.tabs.clear()
|
self.tabs.clear()
|
||||||
self.editors.clear()
|
self.editors.clear()
|
||||||
self.current_project_root = None
|
self.current_project_root = None
|
||||||
self._setup_db_manager()
|
|
||||||
self.explorer.set_root_path(os.getcwd())
|
self.explorer.set_root_path(os.getcwd())
|
||||||
self.setWindowTitle("LyricFlow IDE")
|
self.setWindowTitle("LyricFlow IDE")
|
||||||
self.statusBar().showMessage("Project Closed")
|
self.statusBar().showMessage("Project Closed")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# MARK: - Tab Actions
|
||||||
|
|
||||||
def close_tab(self, index: int):
|
def close_tab(self, index: int):
|
||||||
widget = self.tabs.widget(index)
|
widget = self.tabs.widget(index)
|
||||||
if isinstance(widget, LyricEditor):
|
if isinstance(widget, LyricEditor):
|
||||||
@ -720,6 +725,8 @@ class MainWindow(QMainWindow):
|
|||||||
cursor.insertText("\n" + text)
|
cursor.insertText("\n" + text)
|
||||||
editor.setTextCursor(cursor)
|
editor.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# MARK: - Preferences And Session State
|
||||||
|
|
||||||
def toggle_word_wrap(self, enabled: bool):
|
def toggle_word_wrap(self, enabled: bool):
|
||||||
self.word_wrap_enabled = enabled
|
self.word_wrap_enabled = enabled
|
||||||
for editor in self._iter_open_editors():
|
for editor in self._iter_open_editors():
|
||||||
@ -727,66 +734,69 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def open_preferences(self):
|
def open_preferences(self):
|
||||||
dialog = PreferencesDialog(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)
|
dialog.clearRequested.connect(self.clear_recovered_session_data)
|
||||||
if dialog.exec():
|
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._apply_preferences_to_ui()
|
||||||
self._save_preferences()
|
self._save_preferences()
|
||||||
self.statusBar().showMessage("Preferences updated")
|
self.statusBar().showMessage("Preferences updated")
|
||||||
|
|
||||||
def clear_recovered_session_data(self, scope: str):
|
def clear_recovered_session_data(self, scope: str):
|
||||||
if scope == "workspace":
|
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")
|
self.statusBar().showMessage("Recovered session data cleared for current workspace")
|
||||||
elif scope == "all":
|
elif scope == "all":
|
||||||
self.session_store.clear()
|
self.client.clear_session()
|
||||||
self.statusBar().showMessage("Recovered session data cleared for all workspaces")
|
self.statusBar().showMessage("Recovered session data cleared for all workspaces")
|
||||||
|
|
||||||
def _apply_preferences_to_ui(self):
|
def _apply_preferences_to_ui(self):
|
||||||
if self.preferences.window_geometry:
|
if self.ui_preferences.window_geometry:
|
||||||
self.restoreGeometry(QByteArray(self.preferences.window_geometry))
|
self.restoreGeometry(QByteArray(self.ui_preferences.window_geometry))
|
||||||
|
|
||||||
if self.preferences.splitter_sizes and len(self.preferences.splitter_sizes) == 3:
|
if self.ui_preferences.splitter_sizes and len(self.ui_preferences.splitter_sizes) == 4:
|
||||||
self.splitter.setSizes([int(v) for v in self.preferences.splitter_sizes])
|
self.splitter.setSizes([int(v) for v in self.ui_preferences.splitter_sizes])
|
||||||
|
|
||||||
self.word_wrap_action.blockSignals(True)
|
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.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.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.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.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.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):
|
def _restore_startup_state(self):
|
||||||
if (
|
if (
|
||||||
self.preferences.reopen_last_project
|
self.core_preferences.reopen_last_project
|
||||||
and self.preferences.last_project_file
|
and self.core_preferences.last_project_file
|
||||||
and os.path.exists(self.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()
|
self._restore_session_snapshots()
|
||||||
|
|
||||||
def _save_preferences(self):
|
def _save_preferences(self):
|
||||||
self.preferences.word_wrap_default = bool(self.word_wrap_enabled)
|
self.ui_preferences.word_wrap_default = bool(self.word_wrap_enabled)
|
||||||
self.preferences.show_left_sidebar = bool(self.left_sidebar_action.isChecked())
|
self.ui_preferences.show_left_sidebar = bool(self.left_sidebar_action.isChecked())
|
||||||
self.preferences.show_right_sidebar = bool(self.right_sidebar_action.isChecked())
|
self.ui_preferences.show_right_sidebar = bool(self.right_sidebar_action.isChecked())
|
||||||
self.preferences.window_geometry = bytes(self.saveGeometry())
|
self.ui_preferences.window_geometry = bytes(self.saveGeometry())
|
||||||
self.preferences.splitter_sizes = self.splitter.sizes()
|
self.ui_preferences.splitter_sizes = self.splitter.sizes()
|
||||||
if self.current_project_root:
|
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.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:
|
def _current_workspace_root(self) -> str | None:
|
||||||
if self.current_project_root:
|
if self.current_project_root:
|
||||||
@ -840,13 +850,13 @@ class MainWindow(QMainWindow):
|
|||||||
return snapshots
|
return snapshots
|
||||||
|
|
||||||
def _save_session_snapshots(self):
|
def _save_session_snapshots(self):
|
||||||
if not self.preferences.restore_unsaved_tabs:
|
if not self.core_preferences.restore_unsaved_tabs:
|
||||||
return
|
return
|
||||||
snapshots = self._collect_session_snapshots()
|
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):
|
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:
|
if not snapshots:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -939,6 +949,8 @@ class MainWindow(QMainWindow):
|
|||||||
cursor.setPosition(clamped)
|
cursor.setPosition(clamped)
|
||||||
editor.setTextCursor(cursor)
|
editor.setTextCursor(cursor)
|
||||||
|
|
||||||
|
# MARK: - Window Lifecycle
|
||||||
|
|
||||||
def closeEvent(self, event: QCloseEvent):
|
def closeEvent(self, event: QCloseEvent):
|
||||||
if not self._confirm_unsaved_changes_for_scope("exiting"):
|
if not self._confirm_unsaved_changes_for_scope("exiting"):
|
||||||
event.ignore()
|
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 (
|
from .api import (
|
||||||
LyricAnalysisService,
|
LyricAnalysisService,
|
||||||
@ -10,34 +14,15 @@ from .api import (
|
|||||||
project_state_service,
|
project_state_service,
|
||||||
)
|
)
|
||||||
from .engine.phonetics import PhoneticProcessor, processor
|
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__ = [
|
__all__ = [
|
||||||
"AppPreferences",
|
|
||||||
"AppSettingsStore",
|
|
||||||
"FileManager",
|
|
||||||
"GLOBAL_WORKSPACE_KEY",
|
|
||||||
"LyricAnalysisService",
|
"LyricAnalysisService",
|
||||||
"LyricFlowCoreFacade",
|
"LyricFlowCoreFacade",
|
||||||
"PhoneticProcessor",
|
"PhoneticProcessor",
|
||||||
"ProjectState",
|
"ProjectState",
|
||||||
"ProjectStateService",
|
"ProjectStateService",
|
||||||
"RhymeEngine",
|
|
||||||
"SessionStore",
|
|
||||||
"SessionTabSnapshot",
|
|
||||||
"SpellcheckEngine",
|
|
||||||
"analysis_service",
|
"analysis_service",
|
||||||
"core_api",
|
"core_api",
|
||||||
"engine",
|
|
||||||
"project_state_service",
|
"project_state_service",
|
||||||
"processor",
|
"processor",
|
||||||
"spellcheck",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,10 +1,22 @@
|
|||||||
from src.lyricflow_core.engine.phonetics import PhoneticProcessor, processor
|
from __future__ import annotations
|
||||||
from src.lyricflow_core.engine.rhyme_engine import RhymeEngine, engine
|
|
||||||
from src.lyricflow_core.engine.spellcheck import SpellcheckEngine, spellcheck
|
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:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -12,44 +24,87 @@ class LyricAnalysisService:
|
|||||||
phonetic_processor: PhoneticProcessor | None = None,
|
phonetic_processor: PhoneticProcessor | None = None,
|
||||||
spellcheck_engine: SpellcheckEngine | None = None,
|
spellcheck_engine: SpellcheckEngine | None = None,
|
||||||
):
|
):
|
||||||
self._engine = rhyme_engine or engine
|
self._engine_override = rhyme_engine
|
||||||
self._processor = phonetic_processor or processor
|
self._processor_override = phonetic_processor
|
||||||
self._spellcheck = spellcheck_engine or spellcheck
|
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:
|
def normalize_word(self, word: str) -> str:
|
||||||
|
if self.bridge.is_alive():
|
||||||
|
return self.bridge.normalize_word(word)
|
||||||
return self._processor.normalize_word(word)
|
return self._processor.normalize_word(word)
|
||||||
|
|
||||||
def phonemes(self, word: str) -> tuple[tuple[str, ...], ...]:
|
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)
|
return self._processor.get_phonemes(word)
|
||||||
|
|
||||||
def count_syllables(self, word: str) -> int:
|
def count_syllables(self, word: str) -> int:
|
||||||
return self._engine.count_syllables(word)
|
return self._engine.count_syllables(word)
|
||||||
|
|
||||||
def rhyme_groups(self, text: str) -> list[dict]:
|
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)
|
return self._engine.get_rhyme_groups(text)
|
||||||
|
|
||||||
def line_densities(self, text: str) -> list[float]:
|
def line_densities(self, text: str) -> list[float]:
|
||||||
return self._engine.get_line_densities(text)
|
return self._engine.get_line_densities(text)
|
||||||
|
|
||||||
def suggestions(self, word: str, limit: int = 20) -> dict[str, list[str]]:
|
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)
|
return self._engine.find_suggestions(word, limit=limit)
|
||||||
|
|
||||||
def synonyms(self, word: str, limit: int = 15) -> dict[str, list[str]]:
|
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)
|
return self._engine.find_synonyms(word, limit=limit)
|
||||||
|
|
||||||
def similarity(self, word1: str, word2: str) -> float:
|
def similarity(self, word1: str, word2: str) -> float:
|
||||||
return self._engine.calculate_similarity(word1, word2)
|
return self._engine.calculate_similarity(word1, word2)
|
||||||
|
|
||||||
def is_known_word(self, word: str) -> bool:
|
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)
|
return self._spellcheck.is_known_word(word)
|
||||||
|
|
||||||
def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]:
|
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)
|
return self._spellcheck.spelling_suggestions(word, limit=limit)
|
||||||
|
|
||||||
def autocorrect_candidate(self, word: str) -> str | None:
|
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)
|
return self._spellcheck.autocorrect_candidate(word)
|
||||||
|
|
||||||
def spelling_issues(self, text: str, suggestion_limit: int = 6) -> list[dict]:
|
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)
|
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
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from src.lyricflow_core.api.backend_bridge import BackendBridge
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProjectState:
|
class ProjectState:
|
||||||
@ -15,7 +19,10 @@ class ProjectState:
|
|||||||
|
|
||||||
|
|
||||||
class ProjectStateService:
|
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]:
|
def parse_cursor_positions(self, raw: Any) -> dict[str, int]:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@ -76,6 +83,13 @@ class ProjectStateService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def read_project(self, project_file: str) -> ProjectState:
|
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)
|
project_file = os.path.abspath(project_file)
|
||||||
with open(project_file, "r", encoding="utf-8") as f:
|
with open(project_file, "r", encoding="utf-8") as f:
|
||||||
payload = json.load(f)
|
payload = json.load(f)
|
||||||
@ -85,6 +99,10 @@ class ProjectStateService:
|
|||||||
return self.from_dict(payload, fallback_name=fallback_name)
|
return self.from_dict(payload, fallback_name=fallback_name)
|
||||||
|
|
||||||
def write_project(self, project_file: str, state: ProjectState) -> None:
|
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)
|
project_file = os.path.abspath(project_file)
|
||||||
payload = self.to_dict(state)
|
payload = self.to_dict(state)
|
||||||
with open(project_file, "w", encoding="utf-8") as f:
|
with open(project_file, "w", encoding="utf-8") as f:
|
||||||
@ -92,4 +110,3 @@ class ProjectStateService:
|
|||||||
|
|
||||||
|
|
||||||
project_state_service = 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
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
@ -8,10 +14,12 @@ from .phonetics import processor
|
|||||||
|
|
||||||
from .syntax import TAG_PATTERN
|
from .syntax import TAG_PATTERN
|
||||||
from .common import is_wordnet_available
|
from .common import is_wordnet_available
|
||||||
|
from src.lyricflow_core.api.backend_bridge import BackendBridge
|
||||||
|
|
||||||
class RhymeEngine:
|
class RhymeEngine:
|
||||||
def __init__(self, threshold: float = 0.5):
|
def __init__(self, threshold: float = 0.5):
|
||||||
self.threshold = threshold
|
self.threshold = threshold
|
||||||
|
self.bridge = BackendBridge()
|
||||||
self._perfect_index: Dict[tuple[str, ...], set[str]] = {}
|
self._perfect_index: Dict[tuple[str, ...], set[str]] = {}
|
||||||
self._slant_index: Dict[str, set[str]] = {}
|
self._slant_index: Dict[str, set[str]] = {}
|
||||||
self._is_indexed = False
|
self._is_indexed = False
|
||||||
@ -48,6 +56,9 @@ class RhymeEngine:
|
|||||||
@lru_cache(maxsize=8192)
|
@lru_cache(maxsize=8192)
|
||||||
def count_syllables(self, word: str) -> int:
|
def count_syllables(self, word: str) -> int:
|
||||||
"""Counts syllables in a word using phonetic data if available."""
|
"""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)
|
phones = processor.get_phonemes(word)
|
||||||
if phones:
|
if phones:
|
||||||
return sum(1 for p in phones[0] if any(char.isdigit() for char in p))
|
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]]:
|
def find_suggestions(self, word: str, limit: int = 20) -> Dict[str, List[str]]:
|
||||||
"""Returns perfect and slant rhymes for a given word."""
|
"""Returns perfect and slant rhymes for a given word."""
|
||||||
|
if self.bridge.is_alive():
|
||||||
|
return self.bridge.get_rhymes(word)
|
||||||
|
|
||||||
self._ensure_indexed()
|
self._ensure_indexed()
|
||||||
word = processor.normalize_word(word)
|
word = processor.normalize_word(word)
|
||||||
phones_list = processor.get_phonemes(word)
|
phones_list = processor.get_phonemes(word)
|
||||||
@ -171,6 +185,11 @@ class RhymeEngine:
|
|||||||
"""Analyzes text to find rhyme groups, respecting LyricDown syntax."""
|
"""Analyzes text to find rhyme groups, respecting LyricDown syntax."""
|
||||||
if text == self._last_group_text:
|
if text == self._last_group_text:
|
||||||
return list(self._last_group_results)
|
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")
|
lines = text.split("\n")
|
||||||
|
|
||||||
@ -255,6 +274,13 @@ class RhymeEngine:
|
|||||||
if text == self._last_density_text:
|
if text == self._last_density_text:
|
||||||
return list(self._last_density_results)
|
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")
|
lines = text.split("\n")
|
||||||
if not lines:
|
if not lines:
|
||||||
return []
|
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 difflib
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@ -13,10 +19,14 @@ from .common import is_wordnet_available
|
|||||||
class SpellcheckEngine:
|
class SpellcheckEngine:
|
||||||
"""Dictionary-backed spell checking for lyrics text."""
|
"""Dictionary-backed spell checking for lyrics text."""
|
||||||
|
|
||||||
|
# MARK: - Lifecycle
|
||||||
|
|
||||||
def __init__(self, phonetic_processor: PhoneticProcessor | None = None):
|
def __init__(self, phonetic_processor: PhoneticProcessor | None = None):
|
||||||
self._processor = phonetic_processor or processor
|
self._processor = phonetic_processor or processor
|
||||||
self._cmu_by_initial: dict[str, list[str]] | None = None
|
self._cmu_by_initial: dict[str, list[str]] | None = None
|
||||||
|
|
||||||
|
# MARK: - Dictionary Index
|
||||||
|
|
||||||
def _build_cmu_index(self) -> dict[str, list[str]]:
|
def _build_cmu_index(self) -> dict[str, list[str]]:
|
||||||
by_initial: dict[str, list[str]] = {}
|
by_initial: dict[str, list[str]] = {}
|
||||||
for word in self._processor.dict.keys():
|
for word in self._processor.dict.keys():
|
||||||
@ -42,6 +52,8 @@ class SpellcheckEngine:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# MARK: - Suggestions
|
||||||
|
|
||||||
def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]:
|
def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]:
|
||||||
normalized = self._processor.normalize_word(word)
|
normalized = self._processor.normalize_word(word)
|
||||||
if not normalized or self.is_known_word(normalized):
|
if not normalized or self.is_known_word(normalized):
|
||||||
@ -53,10 +65,33 @@ class SpellcheckEngine:
|
|||||||
if not length_filtered:
|
if not length_filtered:
|
||||||
length_filtered = candidates
|
length_filtered = candidates
|
||||||
|
|
||||||
suggestions = difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.75)
|
scored: list[tuple[tuple[int, int, float, int, int, int, str], str]] = []
|
||||||
if suggestions:
|
for candidate in length_filtered:
|
||||||
return suggestions
|
distance = self._damerau_levenshtein_distance(normalized, candidate)
|
||||||
return difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.65)
|
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
|
@staticmethod
|
||||||
def _levenshtein_distance(a: str, b: str) -> int:
|
def _levenshtein_distance(a: str, b: str) -> int:
|
||||||
@ -78,6 +113,120 @@ class SpellcheckEngine:
|
|||||||
prev_row = row
|
prev_row = row
|
||||||
return prev_row[-1]
|
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(
|
def autocorrect_candidate(
|
||||||
self,
|
self,
|
||||||
word: str,
|
word: str,
|
||||||
|
|||||||
@ -1,12 +1,7 @@
|
|||||||
from .app_settings import AppPreferences, AppSettingsStore
|
"""Compatibility storage surface.
|
||||||
from .file_manager import FileManager
|
|
||||||
from .session_store import GLOBAL_WORKSPACE_KEY, SessionStore, SessionTabSnapshot
|
|
||||||
|
|
||||||
__all__ = [
|
Active desktop runtime persistence should go through the C# backend and
|
||||||
"AppPreferences",
|
src.gui.ui_settings for Qt-owned UI state.
|
||||||
"AppSettingsStore",
|
"""
|
||||||
"FileManager",
|
|
||||||
"GLOBAL_WORKSPACE_KEY",
|
__all__: list[str] = []
|
||||||
"SessionStore",
|
|
||||||
"SessionTabSnapshot",
|
|
||||||
]
|
|
||||||
|
|||||||
@ -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 dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -48,7 +55,7 @@ class AppSettingsStore:
|
|||||||
else:
|
else:
|
||||||
self._settings.setValue("ui/window_geometry", QByteArray(prefs.window_geometry))
|
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])
|
self._settings.setValue("ui/splitter_sizes", [int(v) for v in prefs.splitter_sizes])
|
||||||
else:
|
else:
|
||||||
self._settings.remove("ui/splitter_sizes")
|
self._settings.remove("ui/splitter_sizes")
|
||||||
@ -75,6 +82,6 @@ class AppSettingsStore:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(parsed) != 3:
|
if len(parsed) != 4:
|
||||||
return None
|
return None
|
||||||
return parsed
|
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 sqlite3
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.lyricflow_core.api.backend_bridge import BackendBridge
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Snapshot:
|
class Snapshot:
|
||||||
id: int
|
id: int
|
||||||
@ -12,10 +20,13 @@ class Snapshot:
|
|||||||
timestamp: float
|
timestamp: float
|
||||||
|
|
||||||
class DatabaseManager:
|
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):
|
def __init__(self, db_path: str):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
|
self.bridge = BackendBridge()
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
def _get_connection(self) -> sqlite3.Connection:
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
@ -23,6 +34,12 @@ class DatabaseManager:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
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:
|
def _init_db(self) -> None:
|
||||||
"""Initialize the database schema if it doesn't exist."""
|
"""Initialize the database schema if it doesn't exist."""
|
||||||
# Ensure the directory exists
|
# Ensure the directory exists
|
||||||
@ -52,6 +69,11 @@ class DatabaseManager:
|
|||||||
|
|
||||||
def save_snapshot(self, file_path: str, content: str) -> None:
|
def save_snapshot(self, file_path: str, content: str) -> None:
|
||||||
"""Saves a new snapshot of the file content."""
|
"""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():
|
if not file_path or not content.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -73,6 +95,19 @@ class DatabaseManager:
|
|||||||
|
|
||||||
def get_snapshots(self, file_path: str) -> List[Snapshot]:
|
def get_snapshots(self, file_path: str) -> List[Snapshot]:
|
||||||
"""Retrieves all snapshots for a given file, ordered newest first."""
|
"""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:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT id, file_path, content, timestamp FROM snapshots WHERE file_path = ? ORDER BY timestamp DESC",
|
"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]
|
return [Snapshot(row['id'], row['file_path'], row['content'], row['timestamp']) for row in cursor]
|
||||||
|
|
||||||
def get_snapshot(self, snapshot_id: int) -> Optional[Snapshot]:
|
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:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT id, file_path, content, timestamp FROM snapshots WHERE id = ?",
|
"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:
|
def save_scratchpad(self, project_id: str, content: str) -> None:
|
||||||
"""Saves or updates the scratchpad content for a project."""
|
"""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:
|
with self._get_connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@ -111,6 +151,12 @@ class DatabaseManager:
|
|||||||
|
|
||||||
def get_scratchpad(self, project_id: str) -> str:
|
def get_scratchpad(self, project_id: str) -> str:
|
||||||
"""Retrieves the scratchpad content for a project. Returns empty string if none found."""
|
"""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:
|
with self._get_connection() as conn:
|
||||||
cursor = conn.execute("SELECT content FROM scratchpads WHERE project_id = ?", (project_id,))
|
cursor = conn.execute("SELECT content FROM scratchpads WHERE project_id = ?", (project_id,))
|
||||||
row = cursor.fetchone()
|
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
|
import os
|
||||||
|
|
||||||
class FileManager:
|
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 dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class TestAppSettingsStore(unittest.TestCase):
|
|||||||
show_right_sidebar=True,
|
show_right_sidebar=True,
|
||||||
last_project_file="C:/demo/.lyricproject",
|
last_project_file="C:/demo/.lyricproject",
|
||||||
window_geometry=b"\x01\x02\x03",
|
window_geometry=b"\x01\x02\x03",
|
||||||
splitter_sizes=[111, 777, 222],
|
splitter_sizes=[111, 777, 222, 333],
|
||||||
)
|
)
|
||||||
store.save(original)
|
store.save(original)
|
||||||
loaded = store.load()
|
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")
|
suggestions = analysis_service.spelling_suggestions("helo")
|
||||||
self.assertIn("hello", suggestions)
|
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):
|
def test_autocorrect_candidate_for_typo(self):
|
||||||
self.assertEqual("nothing", analysis_service.autocorrect_candidate("nothign"))
|
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