Fix lyrics navigation, translation line alignment, and store reset

Navigation: present search as a sheet from library (avoids nested
NavigationStack), use view-based NavigationLink for song rows (fixes
double-push from duplicate navigationDestination).

Translation: Apple Translation inserts a blank line after every
translated line. Strip all blanks from the EN output, then re-insert
them at the same positions where the original ES has blanks. Result
is 1:1 line pairing between Spanish and English.

Store reset: revert localStoreResetVersion bump — adding SavedSong
is a lightweight SwiftData migration, no store nuke needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 23:08:32 -05:00
parent faef20e5b8
commit 636193fae1
5 changed files with 85 additions and 9 deletions

View File

@@ -226,7 +226,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations. /// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again. /// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) { private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 3 // bump: added SavedSong to local schema let resetVersion = 2 // bump: widget schema moved to SharedModels
let key = "localStoreResetVersion" let key = "localStoreResetVersion"
let defaults = UserDefaults.standard let defaults = UserDefaults.standard

View File

@@ -134,16 +134,70 @@ struct LyricsConfirmationView: View {
private func translateLyrics(session: sending TranslationSession) async { private func translateLyrics(session: sending TranslationSession) async {
await MainActor.run { isTranslating = true } await MainActor.run { isTranslating = true }
let text = result.lyricsES let text = result.lyricsES
let esLines = text.components(separatedBy: "\n")
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 ES lines:")
for (i, line) in esLines.prefix(10).enumerated() {
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
do { do {
let response = try await session.translate(text) let response = try await session.translate(text)
await MainActor.run { translatedEN = response.targetText } let enLines = response.targetText.components(separatedBy: "\n")
print("[LyricsTranslation] English line count: \(enLines.count)")
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
print("[LyricsTranslation] First 10 EN lines:")
for (i, line) in enLines.prefix(10).enumerated() {
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
}
// The Translation framework often inserts a blank line after every
// translated line. Collapse consecutive blank lines into single blanks,
// then trim leading/trailing blanks so the EN structure matches the ES.
let normalized = Self.normalizeTranslationLineBreaks(
translated: response.targetText,
originalES: text
)
let normalizedLines = normalized.components(separatedBy: "\n")
print("[LyricsTranslation] After normalization: EN lines \(enLines.count)\(normalizedLines.count) (target: \(esLines.count))")
await MainActor.run { translatedEN = normalized }
} catch { } catch {
print("Translation error: \(error)") print("[LyricsTranslation] Translation error: \(error)")
await MainActor.run { translationError = true } await MainActor.run { translationError = true }
} }
await MainActor.run { isTranslating = false } await MainActor.run { isTranslating = false }
} }
/// Re-align translated line breaks to match the original Spanish structure.
/// The Translation framework often inserts a blank line after every translated
/// line. We strip all blanks from the EN output, then re-insert them at the
/// same positions where the original ES text has blank lines.
/// Re-align translated line breaks to match the original Spanish structure.
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
let esLines = originalES.components(separatedBy: "\n")
let enContentLines = translated.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
// Walk ES lines. For each blank ES line, insert a blank in the result.
// For each content ES line, consume the next EN content line.
var result: [String] = []
var enIndex = 0
for esLine in esLines {
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
result.append("")
} else if enIndex < enContentLines.count {
result.append(enContentLines[enIndex])
enIndex += 1
} else {
result.append("")
}
}
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
return result.joined(separator: "\n")
}
private func saveSong() { private func saveSong() {
let song = SavedSong( let song = SavedSong(
title: result.title, title: result.title,

View File

@@ -4,6 +4,7 @@ import SwiftData
struct LyricsLibraryView: View { struct LyricsLibraryView: View {
@Query(sort: \SavedSong.savedDate, order: .reverse) private var songs: [SavedSong] @Query(sort: \SavedSong.savedDate, order: .reverse) private var songs: [SavedSong]
@State private var showingSearch = false
var body: some View { var body: some View {
Group { Group {
@@ -16,7 +17,9 @@ struct LyricsLibraryView: View {
} else { } else {
List { List {
ForEach(songs) { song in ForEach(songs) { song in
NavigationLink(value: song) { NavigationLink {
LyricsReaderView(song: song)
} label: {
SongRowView(song: song) SongRowView(song: song)
} }
} }
@@ -28,15 +31,19 @@ struct LyricsLibraryView: View {
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
NavigationLink { Button {
LyricsSearchView() showingSearch = true
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
} }
} }
} }
.navigationDestination(for: SavedSong.self) { song in .sheet(isPresented: $showingSearch) {
LyricsReaderView(song: song) NavigationStack {
LyricsSearchView {
showingSearch = false
}
}
} }
} }

View File

@@ -56,6 +56,15 @@ struct LyricsReaderView: View {
let spanishLines = song.lyricsES.components(separatedBy: "\n") let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n") let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count) let lineCount = max(spanishLines.count, englishLines.count)
let _ = {
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
for i in 0..<min(15, lineCount) {
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
let en = i < englishLines.count ? englishLines[i] : "(none)"
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
print(" EN: \(en.isEmpty ? "(blank)" : en)")
}
}()
return VStack(alignment: .leading, spacing: 0) { return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in ForEach(0..<lineCount, id: \.self) { index in

View File

@@ -4,6 +4,8 @@ import SwiftData
import Translation import Translation
struct LyricsSearchView: View { struct LyricsSearchView: View {
var onSaved: (() -> Void)?
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -33,7 +35,11 @@ struct LyricsSearchView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $selectedResult) { result in .navigationDestination(item: $selectedResult) { result in
LyricsConfirmationView(result: result) { LyricsConfirmationView(result: result) {
dismiss() if let onSaved {
onSaved()
} else {
dismiss()
}
} }
} }
} }