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:
stan44 2026-03-15 01:44:56 -05:00
parent ae2ba3d873
commit e0f298ba36
59 changed files with 4655 additions and 274 deletions

14
.gitignore vendored
View File

@ -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

View 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
);

View File

@ -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
{
}

View File

@ -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>

View 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

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -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": "*"
}

View File

@ -0,0 +1,2 @@
<Solution>
</Solution>

View File

@ -0,0 +1,6 @@
namespace LyricFlow.Core;
public class Class1
{
}

View 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
);

View File

@ -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;
}

View 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
}

View 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
}

View 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>

View File

@ -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
{
}

View 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;
}
}

View File

@ -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);
}
}

View 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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);
}

View 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
}

View 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);
}
}

View 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
}

View File

@ -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,

Binary file not shown.

36
run.py
View File

@ -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
View 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
View 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()

View File

@ -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

View File

@ -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):

View File

@ -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:

View File

@ -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(),
),
) )

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,4 @@
import re
TAG_PATTERN = re.compile(r"\[[^\]\n]+\]")

View File

@ -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
View 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

View File

@ -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",
] ]

View File

@ -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)

View 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

View File

@ -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()

View File

@ -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 []

View File

@ -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,

View File

@ -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",
]

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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()

View 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()

View 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()

View 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()

View 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()

View 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()

View File

@ -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"))

View 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()