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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user