diff --git a/README.md b/README.md index fd80e61..df214ed 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **Universal themes** — one theme snapshots the entire look of all presenters; portable `.tptheme` packages travel between machines with all media embedded - **Transparent output window** — invisible on the projector when idle; content fades in from transparency and back out - **Multi-window tabs** (⌘T) — different modules and Bible translations per tab, one shared output -- **Multi-format import** — 6 Bible formats, OpenSong/OpenLyrics/PowerPoint songs, universal drag & drop +- **Multi-format import** — 6 Bible formats, 6 song formats (incl. the GOAT TopPresenter Song JSON with versions + chords), universal drag & drop - **Resolution adaptive** — layouts are defined in percentages and fonts scale from a 1080p reference; any projector resolution, aspect ratio, or PPI just works --- @@ -79,10 +79,14 @@ The design studio behind everything you see on screen: ### 🎵 Songs & Lyrics -- **4 import formats** — OpenSong XML, OpenLyrics XML, PowerPoint (PPTX & PPT — sandbox-safe, in-process parsing) -- Multi-select file/folder import with auto-detection; each PowerPoint slide becomes a verse with section detection (Verse, Chorus, Bridge) -- **Section quick-jump tabs**, ← → navigation, live slide position (first/last/chorus aware) -- **Export** as TopPresenter JSON, OpenLyrics XML, or Plain Text +- **The GOAT format** — **TopPresenter Song JSON v2.0.0** is one file per song and a superset of every source: per-song versions, sections with **inline chords** (ChordPro positions) and **bilingual translation lines**, arrangement/play-order, section **repeat counts**, linked media, and rich metadata all round-trip through import → store → export. +- **Multiple versions per song** — a song groups several renditions (e.g. 3 Romanian variants, an ES translation). Each version owns its own metadata (title shown, authors, language, key/capo/tempo, copyright, CCLI, songbook, style, themes, notes, repeat marker) and **inherits the original's by default**, with a per-version toggle to customize. +- **6 import formats** — TopPresenter Song JSON, OpenSong XML, OpenLyrics XML (translations + chords), ChordPro, plain text, and PowerPoint (PPTX & PPT — sandbox-safe, in-process parsing, with filename titles + chorus-reuse detection). +- **Recursive folder import** for thousands of files, with progress, format auto-detection, and duplicate handling (add as new version / keep both / skip). +- **Scalable browser** — list ⇄ grid with theme-rendered thumbnails, instant indexed search, and filters (collection, language, media). +- **Song studio editor** — two-pane visual editor with a live theme-rendered preview, version tabs, color-coded section cards (drag-to-reorder, duplicate, ×N repeat, inline-chord mode), and per-version metadata. +- **Rendered slide filmstrip** — sections auto-split to fit the screen (configurable lines/slide); click to project, double-click or ▶ to go live; bilingual + repeat markers (`/: :/`, `‖: :‖`, `|: :|`, `(×N)`, `bis/ter`) applied per the theme. +- **Export** as TopPresenter Song JSON (one file per song or a whole folder), OpenLyrics XML, or Plain Text. ### 🖥 Presentation Output @@ -127,7 +131,7 @@ The design studio behind everything you see on screen: | | Formats | |---|---| | **Bible import** | TopPresenter JSON · OSIS XML · Zefania XML · MySword SQLite · USFM · Unbound Bible | -| **Song import** | OpenSong XML · OpenLyrics XML · PowerPoint (`.pptx`, `.ppt`) | +| **Song import** | TopPresenter Song JSON · OpenSong XML · OpenLyrics XML · ChordPro (`.cho`, `.crd`, `.chordpro`) · Plain text (`.txt`) · PowerPoint (`.pptx`, `.ppt`) | | **Media** | Images (jpg, png, gif, heic, tiff, bmp, webp, svg) · Audio (mp3, wav, aac, m4a, flac, ogg, aiff) · Video (mp4, mov, avi, mkv, webm, m4v) | | **Themes** | `.tptheme` packages (theme.json + embedded media) | | **Export** | Bible: TopPresenter JSON, TXT, CSV · Songs: TopPresenter JSON, OpenLyrics, TXT · Themes: `.tptheme` | @@ -149,7 +153,7 @@ It exports straight to **TopPresenter Bible JSON** — clean verse text plus red **Shipped** - [x] Native SwiftUI + SwiftData app — Bible, Songs, Slides, Media, Schedules - [x] Per-presenter theme engine: layouts, transitions, custom & media boxes, portable `.tptheme` -- [x] 6 Bible + 3 song import formats; lossless TopPresenter Bible JSON (GOAT) round-trip +- [x] 6 Bible + 6 song import formats; lossless TopPresenter Bible **and** Song JSON (GOAT) round-trip — songs carry versions, chords, bilingual lines, arrangement & repeats - [x] Red-letter, footnotes, cross-references, headings, Strong's & morphology stored in the DB - [x] Three-column Bible reader (Books · Chapters · Verses) with language groups & canon badges - [x] Drag-and-drop batch import (files *and* folders, recursive); eBiblia exporter diff --git a/TopPresenter.xcodeproj/project.pbxproj b/TopPresenter.xcodeproj/project.pbxproj index fe15921..a1686b0 100644 --- a/TopPresenter.xcodeproj/project.pbxproj +++ b/TopPresenter.xcodeproj/project.pbxproj @@ -390,6 +390,7 @@ A9D0D41E2F65FF4300CE2417 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TopPresenter/TopPresenter.entitlements; @@ -409,7 +410,6 @@ ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_KEY_CFBundleIconFile = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -439,6 +439,7 @@ A9D0D41F2F65FF4300CE2417 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = TopPresenter/TopPresenter.entitlements; @@ -458,7 +459,6 @@ ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_KEY_CFBundleIconFile = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/TopPresenter/Core/Constants.swift b/TopPresenter/Core/Constants.swift index 40aacbd..5e44779 100644 --- a/TopPresenter/Core/Constants.swift +++ b/TopPresenter/Core/Constants.swift @@ -144,24 +144,33 @@ enum BibleBookNumbers { } enum SupportedSongFormat: String, CaseIterable, Identifiable { + case topPresenterJSON = "toppresenter-song" case openSongXML = "opensong" case openLyricsXML = "openlyrics" + case chordPro = "chordpro" + case plainText = "plaintext" case powerPoint = "powerpoint" var id: String { rawValue } var displayName: String { switch self { + case .topPresenterJSON: return String(localized: "TopPresenter Song JSON", comment: "Song format name") case .openSongXML: return String(localized: "OpenSong XML", comment: "Song format name") case .openLyricsXML: return String(localized: "OpenLyrics XML", comment: "Song format name") + case .chordPro: return String(localized: "ChordPro", comment: "Song format name") + case .plainText: return String(localized: "Plain Text", comment: "Song format name") case .powerPoint: return String(localized: "PowerPoint", comment: "Song format name") } } var fileExtensions: [String] { switch self { + case .topPresenterJSON: return ["json"] case .openSongXML: return ["xml"] case .openLyricsXML: return ["xml"] + case .chordPro: return ["cho", "crd", "chordpro", "chopro"] + case .plainText: return ["txt"] case .powerPoint: return ["pptx", "ppt"] } } diff --git a/TopPresenter/Core/DataMigration.swift b/TopPresenter/Core/DataMigration.swift index 3c67737..7be6492 100644 --- a/TopPresenter/Core/DataMigration.swift +++ b/TopPresenter/Core/DataMigration.swift @@ -29,16 +29,46 @@ enum SchemaV1: VersionedSchema { } } +// MARK: - Schema Version 2 (rich Songs: Songbook + SongVersion + SongSection) +// +// Purely additive over V1 — new @Model types and new Song properties with inline +// defaults — so V1→V2 is a lightweight migration (no data loss, no custom code). +// SongVerse is retained as the flattened presentation cache of a song's active version. +enum SchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + + static var models: [any PersistentModel.Type] { + [ + BibleModule.self, + BibleBook.self, + BibleChapter.self, + BibleVerse.self, + SongCollection.self, + Song.self, + SongVerse.self, + Songbook.self, + SongVersion.self, + SongSection.self, + PresentationSlide.self, + ServiceSchedule.self, + ScheduleItem.self, + MediaItem.self, + PresentationStyle.self, + ] + } +} + // MARK: - Migration Plan enum TopPresenterMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [SchemaV1.self] + [SchemaV1.self, SchemaV2.self] } static var stages: [MigrationStage] { - // No migrations yet — will be added as the schema evolves. - // Example for future use: - // .lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self) + // Intentionally empty. The SchemaV1→V2 change is purely additive and is handled by + // SwiftData's automatic lightweight inference (the container is created without a + // staged plan). Staged `.lightweight`/`.custom` stages cannot express adding new + // @Model entities + relationships and throw at construction. [] } } diff --git a/TopPresenter/Core/LibraryManager.swift b/TopPresenter/Core/LibraryManager.swift index 03a8d04..14c4f8e 100644 --- a/TopPresenter/Core/LibraryManager.swift +++ b/TopPresenter/Core/LibraryManager.swift @@ -34,11 +34,24 @@ final class LibraryManager { // MARK: - Song State var selectedSongCollection: SongCollection? var selectedSong: Song? + var selectedSongVersion: SongVersion? var selectedSongVerse: SongVerse? var songSearchQuery: String = "" var songSearchResults: [SongSearchResult] = [] var isSongSearching: Bool = false + /// When set, the Songs view presents the visual song editor for this song. + var songToEdit: Song? + /// When opening the editor from a specific slide, open this version and focus this section. + var songEditVersionID: UUID? + var songEditSectionKey: String? + + // Selected slide (version-aware; drives the sidebar preview + projection from the filmstrip). + var songSlideText: String = "" + var songSlideLabel: String = "" + var songSlideIndex: Int = 0 + var songSlideCount: Int = 1 + // MARK: - Bible Navigation /// Switch translation while staying on the same passage where possible. /// Fallback chain: same book+chapter+verse → same book, first verse → @@ -493,7 +506,26 @@ final class LibraryManager { func selectSong(_ song: Song) { selectedSong = song + selectedSongVersion = song.activeVersion selectedSongVerse = song.sortedVerses.first + songSlideText = "" + songSlideLabel = "" + songSlideIndex = 0 + songSlideCount = 1 + } + + func selectSongVersion(_ version: SongVersion) { + selectedSongVersion = version + songSlideText = "" + songSlideLabel = "" + } + + /// Select a built slide (from the filmstrip) for preview/projection. + func selectSongSlide(text: String, label: String, index: Int, count: Int) { + songSlideText = text + songSlideLabel = label + songSlideIndex = index + songSlideCount = count } func selectSongVerse(_ verse: SongVerse) { diff --git a/TopPresenter/Core/PresentationManager.swift b/TopPresenter/Core/PresentationManager.swift index 8fb62dd..b54f6dd 100644 --- a/TopPresenter/Core/PresentationManager.swift +++ b/TopPresenter/Core/PresentationManager.swift @@ -1149,12 +1149,16 @@ final class PresentationManager { var supportsAffixes: Bool { sourceRaw != "static" && sourceRaw != "auto" } func resolvedText(main: String, reference: String, translation: String, subtitle: String, now: Date = .now, slideNumber: String = "", - footnote: String = "", crossReference: String = "", heading: String = "", gloss: String = "", strongs: String = "") -> String { + footnote: String = "", crossReference: String = "", heading: String = "", gloss: String = "", strongs: String = "", + songAuthor: String = "", songCopyright: String = "", songCCLI: String = "", + songbook: String = "", songStyle: String = "", songKey: String = "", songTempo: String = "") -> String { let value = PresentationManager.resolveBoxSource( sourceRaw, format: sourceFormatRaw, autoValue: text, staticText: text, main: main, reference: reference, translation: translation, subtitle: subtitle, now: now, slideNumber: slideNumber, - footnote: footnote, crossReference: crossReference, heading: heading, gloss: gloss, strongs: strongs + footnote: footnote, crossReference: crossReference, heading: heading, gloss: gloss, strongs: strongs, + songAuthor: songAuthor, songCopyright: songCopyright, songCCLI: songCCLI, + songbook: songbook, songStyle: songStyle, songKey: songKey, songTempo: songTempo ) // Wrap a non-empty live value with the static prefix/suffix. guard supportsAffixes, !value.isEmpty, !(prefix.isEmpty && suffix.isEmpty) else { return value } @@ -1167,7 +1171,9 @@ final class PresentationManager { translation: live.translationName, subtitle: live.subtitle, now: now, slideNumber: live.slideNumberText, footnote: live.footnote, crossReference: live.crossReference, - heading: live.heading, gloss: live.gloss, strongs: live.strongs + heading: live.heading, gloss: live.gloss, strongs: live.strongs, + songAuthor: live.songAuthor, songCopyright: live.songCopyright, songCCLI: live.songCCLI, + songbook: live.songbook, songStyle: live.songStyle, songKey: live.songKey, songTempo: live.songTempo ) } @@ -1390,7 +1396,9 @@ final class PresentationManager { main: String, reference: String, translation: String, subtitle: String, now: Date = .now, slideNumber: String = "", footnote: String = "", crossReference: String = "", heading: String = "", - gloss: String = "", strongs: String = "" + gloss: String = "", strongs: String = "", + songAuthor: String = "", songCopyright: String = "", songCCLI: String = "", + songbook: String = "", songStyle: String = "", songKey: String = "", songTempo: String = "" ) -> String { switch raw { case "mainText": return main @@ -1402,6 +1410,13 @@ final class PresentationManager { case "heading": return heading case "gloss": return gloss case "strongs": return strongs + case "author": return songAuthor + case "copyright": return songCopyright + case "ccli": return songCCLI + case "songbook": return songbook + case "style": return songStyle + case "songKey": return songKey + case "songTempo": return songTempo case "static": return staticText case "date", "time": return formattedClock(source: raw, format: format, now: now) case "slideNumber": return slideNumber @@ -1419,6 +1434,13 @@ final class PresentationManager { ("mainText", String(localized: "Versuri (live)", comment: "Box source")), ("reference", String(localized: "Titlu cântec (live)", comment: "Box source")), ("subtitle", String(localized: "Etichetă strofă (live)", comment: "Box source")), + ("author", String(localized: "Autor (live)", comment: "Box source")), + ("copyright", String(localized: "Copyright (live)", comment: "Box source")), + ("ccli", String(localized: "Număr CCLI (live)", comment: "Box source")), + ("songbook", String(localized: "Carte de cântări (live)", comment: "Box source")), + ("style", String(localized: "Stil (live)", comment: "Box source")), + ("songKey", String(localized: "Tonalitate (live)", comment: "Box source")), + ("songTempo", String(localized: "Tempo (live)", comment: "Box source")), ] case "text": live = [ @@ -2548,10 +2570,30 @@ final class PresentationManager { } } - func showSongVerse(text: String, title: String, verseLabel: String, slideIndex: Int = 0, slideCount: Int = 1) { + func showSongVerse(text: String, title: String, verseLabel: String, slideIndex: Int = 0, slideCount: Int = 1, + song: Song? = nil, version: SongVersion? = nil) { guard !isFrozen else { return } + // A version uses its own metadata only when it overrides; otherwise it inherits the + // original (first) version's. Song-level fields are the final fallback. + let meta = (version?.overridesMetadata == true) ? version : (song?.activeVersion ?? version) + func pick(_ versionValue: String?, _ songValue: String?) -> String { + if let v = versionValue, !v.isEmpty { return v } + return songValue ?? "" + } presentContent { [self] in - liveContent.setSongVerse(text: text, title: title, verseLabel: verseLabel, slideIndex: slideIndex, slideCount: slideCount) + liveContent.setSongVerse( + text: text, + title: pick(meta?.displayTitle, title), + verseLabel: verseLabel, + slideIndex: slideIndex, slideCount: slideCount, + author: pick(meta?.author, song?.author), + copyright: pick(meta?.copyright, song?.copyright), + ccli: pick(meta?.ccliNumber, song?.ccliNumber), + songbook: pick(meta?.songbookName, song?.songbook?.name), + style: pick(meta?.style, song?.style), + key: pick(meta?.key, song?.key), + tempo: pick(meta?.tempo, song?.tempo) + ) lastLiveProfileKey = "song" liveContent.isLive = true isBlackScreen = false @@ -2817,7 +2859,10 @@ final class PresentationManager { autoValue: autoValue, staticText: staticText(for: section, in: key), main: main, reference: reference, translation: translation, subtitle: subtitle, - now: now, slideNumber: slideNumber + now: now, slideNumber: slideNumber, + songAuthor: liveContent.songAuthor, songCopyright: liveContent.songCopyright, + songCCLI: liveContent.songCCLI, songbook: liveContent.songbook, + songStyle: liveContent.songStyle, songKey: liveContent.songKey, songTempo: liveContent.songTempo ) } diff --git a/TopPresenter/Models/PresentationModels.swift b/TopPresenter/Models/PresentationModels.swift index 09be465..2f74370 100644 --- a/TopPresenter/Models/PresentationModels.swift +++ b/TopPresenter/Models/PresentationModels.swift @@ -246,6 +246,14 @@ final class LiveContent { var heading: String = "" var gloss: String = "" var strongs: String = "" + /// Song metadata sources (populated for live song slides; "" otherwise). + var songAuthor: String = "" + var songCopyright: String = "" + var songCCLI: String = "" + var songbook: String = "" + var songStyle: String = "" + var songKey: String = "" + var songTempo: String = "" /// Position of this slide within its set (verse within song, slide within /// deck, item within schedule) — drives "show only on first/last slide". var slideIndex: Int = 0 @@ -281,6 +289,7 @@ final class LiveContent { isLive = false mainRuns = [] clearRichSources() + clearSongMeta() slideIndex = 0 slideCount = 1 } @@ -289,6 +298,11 @@ final class LiveContent { footnote = ""; crossReference = ""; heading = ""; gloss = ""; strongs = "" } + private func clearSongMeta() { + songAuthor = ""; songCopyright = ""; songCCLI = "" + songbook = ""; songStyle = ""; songKey = ""; songTempo = "" + } + func setBibleVerse(text: String, reference: String, translationName: String = "", runs: [VerseRun] = [], footnote: String = "", crossReference: String = "", heading: String = "", gloss: String = "", strongs: String = "", @@ -308,7 +322,9 @@ final class LiveContent { self.slideCount = max(slideCount, 1) } - func setSongVerse(text: String, title: String, verseLabel: String, slideIndex: Int = 0, slideCount: Int = 1) { + func setSongVerse(text: String, title: String, verseLabel: String, slideIndex: Int = 0, slideCount: Int = 1, + author: String = "", copyright: String = "", ccli: String = "", + songbook: String = "", style: String = "", key: String = "", tempo: String = "") { self.mainText = text self.reference = title self.subtitle = verseLabel @@ -316,6 +332,13 @@ final class LiveContent { self.contentType = .song self.mainRuns = [] clearRichSources() + self.songAuthor = author + self.songCopyright = copyright + self.songCCLI = ccli + self.songbook = songbook + self.songStyle = style + self.songKey = key + self.songTempo = tempo self.slideIndex = slideIndex self.slideCount = max(slideCount, 1) } diff --git a/TopPresenter/Models/SongModels.swift b/TopPresenter/Models/SongModels.swift index d438ed6..fac334c 100644 --- a/TopPresenter/Models/SongModels.swift +++ b/TopPresenter/Models/SongModels.swift @@ -8,7 +8,83 @@ import Foundation import SwiftData -// MARK: - Song Collection +// MARK: - JSON helpers (blobs keep the rich layer flexible without exploding the table count) + +func tpEncodeJSON(_ value: T, fallback: String) -> String { + guard let data = try? JSONEncoder().encode(value), + let string = String(data: data, encoding: .utf8) else { return fallback } + return string +} + +func tpDecodeJSON(_ string: String, as type: T.Type, fallback: T) -> T { + guard let data = string.data(using: .utf8), + let value = try? JSONDecoder().decode(T.self, from: data) else { return fallback } + return value +} + +// MARK: - Value Types (encoded into linesJSON / mediaJSON) + +/// A chord placed at a character offset within a line (ChordPro-style). +struct SongChord: Codable, Hashable { + var sym: String // e.g. "G", "D/F#" + var pos: Int // character offset into the line text + + init(sym: String, pos: Int) { + self.sym = sym + self.pos = pos + } +} + +/// One lyric line: text + optional chords + optional per-language translations (bilingual). +struct SongLine: Codable, Hashable { + var text: String + var chords: [SongChord] + var translations: [String: String] // languageCode -> translated line + + init(text: String, chords: [SongChord] = [], translations: [String: String] = [:]) { + self.text = text + self.chords = chords + self.translations = translations + } +} + +/// A linked media asset (audio negative / karaoke / background). +struct SongMediaRef: Codable, Hashable { + var role: String // "negative" | "karaoke" | "audio" | "background" + var kind: String // "audio" | "video" | "image" + var filename: String + var bookmark: String? // base64 security-scoped bookmark, optional + + init(role: String, kind: String, filename: String, bookmark: String? = nil) { + self.role = role + self.kind = kind + self.filename = filename + self.bookmark = bookmark + } +} + +// MARK: - Songbook (canonical hymnal a song may optionally belong to) +@Model +final class Songbook { + @Attribute(.unique) var id: UUID + var name: String + var publisher: String = "" + var language: String = "" + var year: String = "" + + @Relationship(deleteRule: .nullify, inverse: \Song.songbook) + var songs: [Song] = [] + + init(name: String, publisher: String = "", language: String = "", year: String = "") { + self.id = UUID() + self.name = name + self.publisher = publisher + self.language = language + self.year = year + } +} + +// MARK: - Song Collection (import grouping / library) @Model final class SongCollection { @Attribute(.unique) var id: UUID @@ -33,21 +109,42 @@ final class SongCollection { } } -// MARK: - Song +// MARK: - Song (conceptual work; owns one or more versions) @Model final class Song { @Attribute(.unique) var id: UUID var title: String - var author: String + var author: String // legacy combined author (display fallback) var copyright: String var ccliNumber: String var key: String var tempo: String var songNumber: String - var tags: String // comma-separated + var tags: String // comma-separated + + // Rich metadata (v2; additive — inline defaults keep the migration lightweight) + var titlesJSON: String = "[]" // alternate titles / aliases + var language: String = "" + var themesJSON: String = "[]" + var style: String = "" // style-of-singing (imn/coral/contemporan…) + var songbookNumber: String = "" + var authorWords: String = "" + var authorMusic: String = "" + var authorTranslation: String = "" + var notes: String = "" + var mediaJSON: String = "[]" // [SongMediaRef] + var extensionsJSON: String = "{}" // future params (_extensions) + var searchText: String = "" // denormalized lowercase (title+aliases+author+lyrics) var collection: SongCollection? + var songbook: Songbook? + + /// Rich layer — the source of truth for lyrics. + @Relationship(deleteRule: .cascade, inverse: \SongVersion.song) + var versions: [SongVersion] = [] + /// Flattened presentation cache of the active version (legacy consumers: presenter / search / schedule). + /// Regenerated from the active `SongVersion`; never hand-edited. @Relationship(deleteRule: .cascade, inverse: \SongVerse.song) var verses: [SongVerse] = [] @@ -72,16 +169,164 @@ final class Song { self.tags = tags } - var sortedVerses: [SongVerse] { - verses.sorted { $0.order < $1.order } + var sortedVersions: [SongVersion] { versions.sorted { $0.order < $1.order } } + var activeVersion: SongVersion? { sortedVersions.first } + + var sortedVerses: [SongVerse] { verses.sorted { $0.order < $1.order } } + var verseLabels: [String] { sortedVerses.map { $0.label } } + + // Decoded convenience accessors + var titles: [String] { + get { tpDecodeJSON(titlesJSON, as: [String].self, fallback: []) } + set { titlesJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + var themes: [String] { + get { tpDecodeJSON(themesJSON, as: [String].self, fallback: []) } + set { themesJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + var media: [SongMediaRef] { + get { tpDecodeJSON(mediaJSON, as: [SongMediaRef].self, fallback: []) } + set { mediaJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + + /// Denormalized, lowercased search blob used by the scalable library browser. + static func makeSearchText( + title: String, titles: [String] = [], author: String = "", authorWords: String = "", + songNumber: String = "", songbookNumber: String = "", lyrics: String = "" + ) -> String { + (([title] + titles + [author, authorWords, songNumber, songbookNumber, lyrics])) + .filter { !$0.isEmpty } + .joined(separator: " ") + .lowercased() + } +} + +// MARK: - Song Version (a specific rendition: 3 RO versions, an ES translation, an alt arrangement…) +@Model +final class SongVersion { + @Attribute(.unique) var id: UUID + var name: String = "" // distinguishing label, e.g. "Versiunea clasică" + // Per-version metadata. When empty, the song-level value is used instead (overrides). + var displayTitle: String = "" // title shown on screen for THIS version (e.g. a translation) + var author: String = "" + var language: String = "" + var key: String = "" + var capo: Int = 0 + var tempo: String = "" + var timeSignature: String = "" + var copyright: String = "" + var ccliNumber: String = "" + var source: String = "" + var repeatStyle: String = "" // repeat-marker style override ("" = use the global default) + // Per-version overrides for the song's shared ("comun") fields. Used only when + // overridesMetadata is true; empty otherwise (the original's values are inherited). + var titlesJSON: String = "[]" // alternate titles / aliases + var authorWords: String = "" + var authorMusic: String = "" + var authorTranslation: String = "" + var style: String = "" + var songbookNumber: String = "" + var themesJSON: String = "[]" + var notes: String = "" + var songbookName: String = "" // per-version songbook name (book the version is from) + /// When false, this version inherits the original (first) version's metadata; its own + /// override fields are ignored. The "Original" version is always treated as authoritative. + var overridesMetadata: Bool = false + var arrangementJSON: String = "[]" // [sectionKey] play order (section reuse) + var order: Int + + var song: Song? + + @Relationship(deleteRule: .cascade, inverse: \SongSection.version) + var sections: [SongSection] = [] + + init( + name: String = "", + order: Int = 0, + language: String = "", + key: String = "", + capo: Int = 0, + tempo: String = "", + timeSignature: String = "", + copyright: String = "", + ccliNumber: String = "", + source: String = "" + ) { + self.id = UUID() + self.name = name + self.order = order + self.language = language + self.key = key + self.capo = capo + self.tempo = tempo + self.timeSignature = timeSignature + self.copyright = copyright + self.ccliNumber = ccliNumber + self.source = source + } + + var sortedSections: [SongSection] { sections.sorted { $0.order < $1.order } } + + var arrangement: [String] { + get { tpDecodeJSON(arrangementJSON, as: [String].self, fallback: []) } + set { arrangementJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + + var titles: [String] { + get { tpDecodeJSON(titlesJSON, as: [String].self, fallback: []) } + set { titlesJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + var themes: [String] { + get { tpDecodeJSON(themesJSON, as: [String].self, fallback: []) } + set { themesJSON = tpEncodeJSON(newValue, fallback: "[]") } + } + + /// Sections expanded in `arrangement` order (repeats a reused chorus, etc.). + /// Falls back to plain section order when no arrangement is defined. + var arrangedSections: [SongSection] { + let order = arrangement + guard !order.isEmpty else { return sortedSections } + let byKey = Dictionary(sections.map { ($0.sectionKey, $0) }, uniquingKeysWith: { a, _ in a }) + let mapped = order.compactMap { byKey[$0] } + return mapped.isEmpty ? sortedSections : mapped + } +} + +// MARK: - Song Section (verse / chorus / bridge … with rich lines) +@Model +final class SongSection { + @Attribute(.unique) var id: UUID + var sectionKey: String // "v1", "c", "b1" — referenced by a version's arrangement + var type: String // verse/chorus/bridge/prechorus/intro/ending/tag/interlude + var label: String + var order: Int + var repeatCount: Int = 1 // sung N times — rendered with the theme's repeat-marker style + var linesJSON: String = "[]" // [SongLine] — text + chords + translations + var plainText: String // flattened lines (quick render + search) + + var version: SongVersion? + + init(sectionKey: String, type: String, label: String, order: Int, repeatCount: Int = 1, lines: [SongLine] = []) { + self.id = UUID() + self.sectionKey = sectionKey + self.type = type + self.label = label + self.order = order + self.repeatCount = repeatCount + self.plainText = lines.map { $0.text }.joined(separator: "\n") + self.linesJSON = tpEncodeJSON(lines, fallback: "[]") } - var verseLabels: [String] { - sortedVerses.map { $0.label } + var lines: [SongLine] { + get { tpDecodeJSON(linesJSON, as: [SongLine].self, fallback: []) } + set { + linesJSON = tpEncodeJSON(newValue, fallback: "[]") + plainText = newValue.map { $0.text }.joined(separator: "\n") + } } } -// MARK: - Song Verse +// MARK: - Song Verse (flattened presentation cache; see Song.verses) @Model final class SongVerse { @Attribute(.unique) var id: UUID diff --git a/TopPresenter/Services/Export/ExportService.swift b/TopPresenter/Services/Export/ExportService.swift index 001370b..58b7850 100644 --- a/TopPresenter/Services/Export/ExportService.swift +++ b/TopPresenter/Services/Export/ExportService.swift @@ -272,47 +272,57 @@ final class ExportService { progressHandler?(1.0, String(localized: "Complete!", comment: "Export progress")) } - // MARK: - TopPresenter Songs JSON Export + // MARK: - TopPresenter Song JSON Export (GOAT v2.0.0 — one song per file) + static let songExporterVersion = "2.0.0" + + /// Serialize a single song to the canonical single-song GOAT document. + static func exportSongToTopPresenterJSON(_ song: Song) throws -> String { + let result: [String: Any] = [ + "schemaVersion": songExporterVersion, + "format": "TopPresenter Song", + "exportInfo": [ + "source": "TopPresenter", + "exportDate": ISO8601DateFormatter().string(from: Date()), + "exporterVersion": songExporterVersion + ], + "song": songDictV2(song) + ] + return try jsonString(from: result) + } + + /// Bulk export: write one GOAT file per song into a directory. + static func exportSongsToFolder( + _ songs: [Song], + directory: URL, + progressHandler: ((Double, String) -> Void)? = nil + ) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let total = max(songs.count, 1) + for (index, song) in songs.enumerated() { + let json = try exportSongToTopPresenterJSON(song) + let name = sanitizeFilename(song.title.isEmpty ? "song-\(index + 1)" : song.title) + let url = directory.appendingPathComponent("\(name).json") + try json.write(to: url, atomically: true, encoding: .utf8) + progressHandler?(Double(index + 1) / Double(total), String(localized: "Exporting \(song.title)...", comment: "Export progress")) + } + } + + /// Bundle several songs into a single file (array of GOAT song objects). private static func exportSongsToTopPresenterJSON( collection: SongCollection, progressHandler: ((Double, String) -> Void)? ) async throws -> String { let sortedSongs = collection.sortedSongs - let totalSongs = sortedSongs.count - + let total = max(sortedSongs.count, 1) var songsArray: [[String: Any]] = [] - for (index, song) in sortedSongs.enumerated() { - var versesArray: [[String: Any]] = [] - for verse in song.sortedVerses { - versesArray.append([ - "label": verse.label, - "verseType": verse.verseType, - "text": verse.text, - "order": verse.order - ]) - } - - let songDict: [String: Any] = [ - "title": song.title, - "author": song.author, - "copyright": song.copyright, - "ccliNumber": song.ccliNumber, - "key": song.key, - "tempo": song.tempo, - "songNumber": song.songNumber, - "tags": song.tags, - "verses": versesArray - ] - songsArray.append(songDict) - - let progress = 0.1 + (Double(index + 1) / Double(totalSongs)) * 0.8 - progressHandler?(progress, String(localized: "Exporting \(song.title)...", comment: "Export progress")) + songsArray.append(songDictV2(song)) + progressHandler?(0.1 + (Double(index + 1) / Double(total)) * 0.8, + String(localized: "Exporting \(song.title)...", comment: "Export progress")) } - let result: [String: Any] = [ - "schemaVersion": "1.0.0", + "schemaVersion": songExporterVersion, "format": "TopPresenter Songs", "collection": [ "name": collection.name, @@ -322,17 +332,106 @@ final class ExportService { "exportInfo": [ "source": "TopPresenter", "exportDate": ISO8601DateFormatter().string(from: Date()), - "exporterVersion": "1.0.0", + "exporterVersion": songExporterVersion, "totalSongs": sortedSongs.count ], "songs": songsArray ] + return try jsonString(from: result) + } - let jsonData = try JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]) - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw ExportError.encodingFailed + // MARK: GOAT dictionary builders + + static func songDictV2(_ song: Song) -> [String: Any] { + var dict: [String: Any] = [ + "title": song.title, + "language": song.language, + "style": song.style, + "copyright": song.copyright, + "ccliNumber": song.ccliNumber, + "notes": song.notes + ] + if !song.titles.isEmpty { dict["titles"] = song.titles } + if !song.themes.isEmpty { dict["themes"] = song.themes } + if !song.author.isEmpty { dict["author"] = song.author } + if !song.authorWords.isEmpty { dict["authorWords"] = song.authorWords } + if !song.authorMusic.isEmpty { dict["authorMusic"] = song.authorMusic } + if !song.authorTranslation.isEmpty { dict["authorTranslation"] = song.authorTranslation } + if let sb = song.songbook { + dict["songbook"] = [ + "name": sb.name, "publisher": sb.publisher, + "language": sb.language, "year": sb.year, "number": song.songbookNumber + ] + } else if !song.songNumber.isEmpty { + dict["songNumber"] = song.songNumber } - return jsonString + let media = song.media.map { m -> [String: Any] in + var d: [String: Any] = ["role": m.role, "kind": m.kind, "filename": m.filename] + if let b = m.bookmark { d["bookmark"] = b } + return d + } + if !media.isEmpty { dict["media"] = media } + dict["versions"] = song.sortedVersions.map { versionDictV2($0) } + return dict + } + + private static func versionDictV2(_ version: SongVersion) -> [String: Any] { + var dict: [String: Any] = [ + "name": version.name, + "language": version.language, + "key": version.key, + "capo": version.capo, + "tempo": version.tempo, + "timeSignature": version.timeSignature, + "copyright": version.copyright, + "ccliNumber": version.ccliNumber, + "source": version.source + ] + if !version.displayTitle.isEmpty { dict["displayTitle"] = version.displayTitle } + if !version.author.isEmpty { dict["author"] = version.author } + if !version.authorWords.isEmpty { dict["authorWords"] = version.authorWords } + if !version.authorMusic.isEmpty { dict["authorMusic"] = version.authorMusic } + if !version.authorTranslation.isEmpty { dict["authorTranslation"] = version.authorTranslation } + if !version.style.isEmpty { dict["style"] = version.style } + if !version.songbookNumber.isEmpty { dict["songbookNumber"] = version.songbookNumber } + if !version.songbookName.isEmpty { dict["songbook"] = ["name": version.songbookName] } + if !version.themes.isEmpty { dict["themes"] = version.themes } + if !version.notes.isEmpty { dict["notes"] = version.notes } + if !version.repeatStyle.isEmpty { dict["repeatStyle"] = version.repeatStyle } + if version.overridesMetadata { dict["overridesMetadata"] = true } + if !version.arrangement.isEmpty { dict["arrangement"] = version.arrangement } + dict["sections"] = version.sortedSections.map { sectionDictV2($0) } + return dict + } + + private static func sectionDictV2(_ section: SongSection) -> [String: Any] { + let lines = section.lines.map { line -> [String: Any] in + var l: [String: Any] = ["text": line.text] + if !line.chords.isEmpty { l["chords"] = line.chords.map { ["sym": $0.sym, "pos": $0.pos] } } + if !line.translations.isEmpty { l["translations"] = line.translations } + return l + } + var dict: [String: Any] = [ + "id": section.sectionKey, + "type": section.type, + "label": section.label, + "order": section.order, + "lines": lines + ] + if section.repeatCount > 1 { dict["repeat"] = section.repeatCount } + return dict + } + + private static func jsonString(from object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + guard let string = String(data: data, encoding: .utf8) else { throw ExportError.encodingFailed } + return string + } + + private static func sanitizeFilename(_ name: String) -> String { + let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") + let cleaned = name.components(separatedBy: invalid).joined(separator: "-") + return String(cleaned.prefix(120)).trimmingCharacters(in: .whitespaces) } // MARK: - OpenLyrics XML Export diff --git a/TopPresenter/Services/Import/ChordProImporter.swift b/TopPresenter/Services/Import/ChordProImporter.swift new file mode 100644 index 0000000..304b7fe --- /dev/null +++ b/TopPresenter/Services/Import/ChordProImporter.swift @@ -0,0 +1,182 @@ +// +// ChordProImporter.swift +// TopPresenter +// +// Created by Cosmin Calin on 17/06/2026. +// + +import Foundation + +/// Importer for ChordPro / Chord chart files (.cho, .crd, .chordpro, .chopro). +/// Parses `{directive: value}` metadata, `{start_of_*}` section blocks, and inline +/// `[Chord]` markers (kept as `SongChord` positions so the GOAT format preserves chords). +final class ChordProImporter: SongImporter { + let format: SupportedSongFormat = .chordPro + + func parse(fileURL: URL) async throws -> SongImportResult { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw SongImportError.fileNotFound + } + let data = try Data(contentsOf: fileURL) + guard !data.isEmpty else { throw SongImportError.emptyFile } + guard let content = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) else { + throw SongImportError.parsingFailed("Unreadable text encoding") + } + return Self.parse(content: content, fallbackTitle: fileURL.deletingPathExtension().lastPathComponent) + } + + static func parse(content: String, fallbackTitle: String) -> SongImportResult { + var title = "" + var subtitle = "" + var artist = "" + var composer = "" + var lyricist = "" + var key = "" + var tempo = "" + var ccli = "" + var copyright = "" + var capo = 0 + var themes: [String] = [] + + var sections: [SongImportSection] = [] + var curLines: [SongLine] = [] + var curType = "verse" + var curLabel = "" + var inExplicitSection = false + var counters: [String: Int] = [:] + + func nextKey(_ type: String) -> (key: String, label: String, order: Int) { + let n = (counters[type] ?? 0) + 1 + counters[type] = n + let order = sections.count + switch type { + case "chorus": return (n == 1 ? "c" : "c\(n)", n == 1 ? "Chorus" : "Chorus \(n)", order) + case "bridge": return ("b\(n)", n == 1 ? "Bridge" : "Bridge \(n)", order) + case "prechorus": return ("p\(n)", "Pre-Chorus", order) + case "intro": return ("i", "Intro", order) + case "ending": return ("e", "Ending", order) + case "tag": return ("t\(n)", "Tag", order) + case "interlude": return ("int\(n)", "Interlude", order) + default: return ("v\(n)", "Verse \(n)", order) + } + } + + func flush() { + guard !curLines.isEmpty else { return } + let meta = nextKey(curType) + let label = curLabel.isEmpty ? meta.label : curLabel + sections.append(SongImportSection(sectionKey: meta.key, type: curType, label: label, order: meta.order, lines: curLines)) + curLines = [] + curLabel = "" + } + + for rawLine in content.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if let dir = directive(trimmed) { + switch dir.key { + case "title", "t": title = dir.value + case "subtitle", "st": subtitle = dir.value + case "artist": artist = dir.value + case "composer": composer = dir.value + case "lyricist", "author": lyricist = dir.value + case "key": key = dir.value + case "tempo", "bpm": tempo = dir.value + case "ccli": ccli = dir.value + case "copyright": copyright = dir.value + case "capo": capo = Int(dir.value) ?? 0 + case "tag": if !dir.value.isEmpty { themes.append(dir.value) } + case "comment", "c", "highlight": break // not lyrics + case "start_of_verse", "sov": + flush(); curType = "verse"; curLabel = dir.value; inExplicitSection = true + case "start_of_chorus", "soc": + flush(); curType = "chorus"; curLabel = dir.value; inExplicitSection = true + case "start_of_bridge", "sob": + flush(); curType = "bridge"; curLabel = dir.value; inExplicitSection = true + case "start_of_part", "sop": + flush(); curType = "other"; curLabel = dir.value; inExplicitSection = true + case "end_of_verse", "eov", "end_of_chorus", "eoc", "end_of_bridge", "eob", "end_of_part", "eop": + flush(); curType = "verse"; inExplicitSection = false + default: + break + } + continue + } + + if trimmed.isEmpty { + // Blank line ends an implicit stanza (when no explicit section markers). + if !inExplicitSection { flush(); curType = "verse" } + continue + } + + let parsed = extractChords(line) + curLines.append(SongLine(text: parsed.text, chords: parsed.chords)) + } + flush() + + let resolvedTitle = !title.isEmpty ? title : (fallbackTitle.isEmpty ? "Untitled" : fallbackTitle) + let author = [lyricist, composer, artist].first(where: { !$0.isEmpty }) ?? "" + var titles: [String] = [] + if !subtitle.isEmpty { titles.append(subtitle) } + + let version = SongImportVersion( + name: "Original", key: key, capo: capo, tempo: tempo, source: "ChordPro", sections: sections + ) + + // If parsing produced nothing usable, keep a single empty verse so the song still imports. + let usableVersion = sections.isEmpty + ? SongImportVersion(name: "Original", sections: [SongImportSection(sectionKey: "v1", type: "verse", label: "Verse 1", order: 0, lines: [])]) + : version + + return SongImportResult( + title: resolvedTitle, + author: author, + copyright: copyright, + ccliNumber: ccli, + key: key, + tempo: tempo, + songNumber: "", + tags: themes.joined(separator: ", "), + verses: [], + titles: titles, + themes: themes, + authorWords: lyricist, + authorMusic: composer, + versions: [usableVersion] + ) + } + + // MARK: - Helpers + + /// Parse a `{key: value}` or `{key}` directive. + private static func directive(_ line: String) -> (key: String, value: String)? { + guard line.hasPrefix("{"), line.hasSuffix("}") else { return nil } + let inner = String(line.dropFirst().dropLast()) + if let colon = inner.firstIndex(of: ":") { + let k = String(inner[.. (text: String, chords: [SongChord]) { + var text = "" + var chords: [SongChord] = [] + var i = line.startIndex + while i < line.endIndex { + let ch = line[i] + if ch == "[" , let close = line[i...].firstIndex(of: "]") { + let sym = String(line[line.index(after: i).. Bool = { false }, progressHandler: ((Double, String) -> Void)? = nil ) async -> SongBatchResult { var result = SongBatchResult() - // Expand directories into their files (flat, like before) + // Expand directories RECURSIVELY (subfolders included); keep the immediate parent for tagging. var fileURLs: [(url: URL, parent: URL?)] = [] for url in urls { - let accessing = url.startAccessingSecurityScopedResource() + _ = url.startAccessingSecurityScopedResource() // kept open until the function ends var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { - let contents = (try? FileManager.default.contentsOfDirectory( - at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] - )) ?? [] - for child in contents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - fileURLs.append((child, url)) - } - if contents.isEmpty { + let files = recursiveSongFiles(in: url) + if files.isEmpty { result.failures.append((url.lastPathComponent, String(localized: "Directorul este gol.", comment: "Import error"))) } + for f in files { fileURLs.append((f, f.deletingLastPathComponent())) } } else { fileURLs.append((url, nil)) } - if accessing { /* keep open until the end of this function */ } } guard !fileURLs.isEmpty else { return result } - // Find or create the collection lazily (only once something imports) + // Duplicate-detection index (normalized title → existing song), built from the collection. + var index: [String: Song] = [:] + var indexBuilt = false func collection() -> SongCollection { if let existing = result.collection { return existing } let descriptor = FetchDescriptor( predicate: #Predicate { $0.name == collectionName } ) + let col: SongCollection if let found = (try? modelContext.fetch(descriptor))?.first { - result.collection = found - return found + col = found + } else { + col = SongCollection(name: collectionName, sourceFormat: "mixed") + modelContext.insert(col) + } + result.collection = col + if !indexBuilt { + for s in col.songs { index[normalizedSongKey(s.title)] = s } + indexBuilt = true } - let fresh = SongCollection(name: collectionName, sourceFormat: "mixed") - modelContext.insert(fresh) - result.collection = fresh - return fresh + return col } - for (index, item) in fileURLs.enumerated() { + for (offset, item) in fileURLs.enumerated() { + if isCancelled() { break } let name = item.url.lastPathComponent progressHandler?( - Double(index) / Double(fileURLs.count), + Double(offset) / Double(fileURLs.count), String(localized: "Se importă \(name)…", comment: "Import progress") ) guard let format = detectSongFormat(fileURL: item.url) else { - result.failures.append((name, String(localized: "Format necunoscut (acceptat: OpenSong/OpenLyrics XML, PPTX, PPT).", comment: "Import error"))) + result.failures.append((name, String(localized: "Format necunoscut (acceptat: TopPresenter/OpenSong/OpenLyrics, ChordPro, TXT, PPTX, PPT).", comment: "Import error"))) continue } guard let importer = songImporters[format] else { @@ -564,13 +575,34 @@ final class ImportService { continue } - let accessing = item.url.startAccessingSecurityScopedResource() - defer { if accessing { item.url.stopAccessingSecurityScopedResource() } } - do { let parsed = try await importer.parse(fileURL: item.url) - _ = createSongFromResult(parsed, collection: collection(), modelContext: modelContext) - result.importedTitles.append(parsed.title) + let col = collection() + let key = normalizedSongKey(parsed.title) + + func makeNew(updateIndex: Bool) { + let song = createSongFromResult(parsed, collection: col, modelContext: modelContext) + applyFolderTag(item.parent, to: song) + if updateIndex { index[key] = song } + result.importedTitles.append(parsed.title) + } + + if let existing = index[key] { + switch duplicateResolution { + case .addAsVersion: + appendVersions(from: parsed, to: existing) + result.importedTitles.append(parsed.title) + case .replace: + modelContext.delete(existing) + makeNew(updateIndex: true) + case .keepBoth: + makeNew(updateIndex: false) + case .skip: + break + } + } else { + makeNew(updateIndex: true) + } } catch { result.failures.append((name, error.localizedDescription)) } @@ -583,6 +615,34 @@ final class ImportService { return result } + /// Recursively list regular files under a directory (subfolders included). + private static func recursiveSongFiles(in dir: URL) -> [URL] { + var out: [URL] = [] + let keys: Set = [.isRegularFileKey] + if let en = FileManager.default.enumerator(at: dir, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles]) { + for case let f as URL in en { + if (try? f.resourceValues(forKeys: keys))?.isRegularFile == true { + out.append(f) + } + } + } + return out.sorted { $0.path < $1.path } + } + + /// Tag a song with its immediate folder name (folder structure → searchable tags/themes). + private static func applyFolderTag(_ parent: URL?, to song: Song) { + guard let parent else { return } + let folder = parent.lastPathComponent + guard !folder.isEmpty else { return } + var themes = song.themes + if !themes.contains(folder) { themes.append(folder); song.themes = themes } + if song.tags.isEmpty { + song.tags = folder + } else if !song.tags.contains(folder) { + song.tags += ", \(folder)" + } + } + /// Auto-detect song format from file content static func detectSongFormat(fileURL: URL) -> SupportedSongFormat? { let ext = fileURL.pathExtension.lowercased() @@ -591,15 +651,33 @@ final class ImportService { if ext == "pptx" || ext == "ppt" { return .powerPoint } + // ChordPro by extension + if ["cho", "crd", "chordpro", "chopro"].contains(ext) { + return .chordPro + } guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { return nil } guard let content = String(data: data.prefix(2000), encoding: .utf8) else { return nil } + // TopPresenter Song JSON (single-song doc or legacy bundle) + if ext == "json" || content.contains("\"format\"") { + if content.contains("TopPresenter Song") || content.contains("\"versions\"") || content.contains("\"verses\"") { + return .topPresenterJSON + } + } if content.contains("") || content.contains("")) { return .openSongXML } + // ChordPro by content (directives) + if content.contains("{title:") || content.contains("{t:") || content.contains("{start_of_") { + return .chordPro + } + // Plain text fallback + if ext == "txt" { + return .plainText + } return nil } @@ -622,17 +700,146 @@ final class ImportService { tags: result.tags ) song.collection = collection + song.titles = result.titles + song.language = result.language + song.themes = result.themes + song.style = result.style + song.songbookNumber = result.songbook?.number ?? "" + song.authorWords = result.authorWords + song.authorMusic = result.authorMusic + song.authorTranslation = result.authorTranslation + song.notes = result.notes + song.media = result.media.map { + SongMediaRef(role: $0.role, kind: $0.kind, filename: $0.filename, bookmark: $0.bookmark) + } - for importVerse in result.verses { - let verse = SongVerse( - label: importVerse.label, - verseType: importVerse.verseType, - text: importVerse.text, - order: importVerse.order - ) - verse.song = song + if let sb = result.songbook, !sb.name.isEmpty { + song.songbook = upsertSongbook(sb, modelContext: modelContext) } + // Build the rich version graph and attach it to the song. + let builtVersions = makeVersions(from: result) + for v in builtVersions { v.song = song } + let activeVersion = builtVersions.first + + // Flatten the active version into the SongVerse cache (legacy presenter / search / schedule). + var lyrics = "" + if let active = activeVersion { + for (i, sec) in active.sortedSections.enumerated() { + let verse = SongVerse(label: sec.label, verseType: sec.type, text: sec.plainText, order: i) + verse.song = song + lyrics += " " + sec.plainText + } + } + song.searchText = Song.makeSearchText( + title: song.title, titles: song.titles, author: song.author, + authorWords: song.authorWords, songNumber: song.songNumber, + songbookNumber: song.songbookNumber, lyrics: lyrics + ) + return song } + + /// Reuse an existing Songbook with the same name, or create and insert a new one. + private static func upsertSongbook(_ sb: SongImportSongbook, modelContext: ModelContext) -> Songbook { + let name = sb.name + let descriptor = FetchDescriptor<Songbook>(predicate: #Predicate { $0.name == name }) + if let existing = try? modelContext.fetch(descriptor).first { + return existing + } + let book = Songbook(name: sb.name, publisher: sb.publisher, language: sb.language, year: sb.year) + modelContext.insert(book) + return book + } + + /// Build the rich version graph from an import result (not yet attached to a song). + /// When the importer only produced flat verses, synthesize a single "Original" version. + private static func makeVersions(from result: SongImportResult) -> [SongVersion] { + let importVersions: [SongImportVersion] + if !result.versions.isEmpty { + importVersions = result.versions + } else { + let sections = result.verses.map { v in + SongImportSection( + sectionKey: v.label.isEmpty ? "s\(v.order)" : v.label, + type: v.verseType, + label: v.label, + order: v.order, + lines: v.text.components(separatedBy: "\n").map { SongLine(text: $0) } + ) + } + importVersions = [SongImportVersion(name: "Original", key: result.key, tempo: result.tempo, sections: sections)] + } + + return importVersions.enumerated().map { vi, iv in + let version = SongVersion( + name: iv.name.isEmpty ? "Versiunea \(vi + 1)" : iv.name, + order: vi, + language: iv.language, + key: iv.key, + capo: iv.capo, + tempo: iv.tempo, + timeSignature: iv.timeSignature, + copyright: iv.copyright, + ccliNumber: iv.ccliNumber, + source: iv.source + ) + version.displayTitle = iv.displayTitle + version.author = iv.author + version.titles = iv.titles + version.authorWords = iv.authorWords + version.authorMusic = iv.authorMusic + version.authorTranslation = iv.authorTranslation + version.style = iv.style + version.songbookNumber = iv.songbookNumber + version.themes = iv.themes + version.notes = iv.notes + version.songbookName = iv.songbookName + version.repeatStyle = iv.repeatStyle + version.overridesMetadata = iv.overridesMetadata + version.arrangement = iv.arrangement + for sec in iv.sections.sorted(by: { $0.order < $1.order }) { + let section = SongSection( + sectionKey: sec.sectionKey, type: sec.type, label: sec.label, + order: sec.order, repeatCount: sec.repeatCount, lines: sec.lines + ) + section.version = version + } + return version + } + } + + /// Append an import result as additional version(s) of an existing song (the user's + /// "a song can have 3 versions" case). The active/first version — and therefore the + /// flattened SongVerse cache — is unchanged; only searchText grows. + private static func appendVersions(from result: SongImportResult, to song: Song) { + let newVersions = makeVersions(from: result) + let base = song.versions.count + var extraLyrics = "" + for (i, version) in newVersions.enumerated() { + version.order = base + i + if version.name.isEmpty || version.name == "Original" { + version.name = "Versiunea \(base + i + 1)" + } + version.song = song + for sec in version.sortedSections { extraLyrics += " " + sec.plainText } + } + if !extraLyrics.isEmpty { song.searchText += " " + extraLyrics.lowercased() } + } + + /// Normalized key for duplicate detection (diacritic- and case-insensitive, whitespace-collapsed). + static func normalizedSongKey(_ title: String) -> String { + title.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} + +/// What to do when an imported song matches one already in the library. +enum SongDuplicateResolution { + case addAsVersion // append the import as a new version of the existing song + case replace // delete the existing song, import fresh + case keepBoth // import as a separate song + case skip // ignore the import } diff --git a/TopPresenter/Services/Import/OpenLyricsImporter.swift b/TopPresenter/Services/Import/OpenLyricsImporter.swift index f89ee98..949be88 100644 --- a/TopPresenter/Services/Import/OpenLyricsImporter.swift +++ b/TopPresenter/Services/Import/OpenLyricsImporter.swift @@ -10,6 +10,9 @@ import Foundation /// Importer for OpenLyrics XML format songs. /// OpenLyrics is an open standard for exchanging song lyrics between applications. /// Specification: http://openlyrics.org +/// +/// Rich mapping: multiple `<title>`s → aliases, `<theme>`s → themes, inline `<chord>` → +/// SongChord positions, and same-named verses in different `lang`s → bilingual translations. final class OpenLyricsImporter: SongImporter { let format: SupportedSongFormat = .openLyricsXML @@ -32,6 +35,35 @@ final class OpenLyricsImporter: SongImporter { } let title = delegate.titles.first ?? fileURL.deletingPathExtension().lastPathComponent + let aliases = Array(delegate.titles.dropFirst()) + + // Build rich sections, merging translation languages into each line. + var sections: [SongImportSection] = [] + for (idx, group) in delegate.orderedVerses.enumerated() { + let (key, label, type) = classify(group.name, index: idx) + let primary = group.primaryLang + var lines = group.lines[primary] ?? [] + for (lang, transLines) in group.lines where lang != primary { + for (i, tline) in transLines.enumerated() where i < lines.count { + if !tline.text.isEmpty { lines[i].translations[lang] = tline.text } + } + } + guard !lines.isEmpty else { continue } + sections.append(SongImportSection(sectionKey: key, type: type, label: label, order: idx, lines: lines)) + } + + let version = SongImportVersion( + name: "Original", + language: delegate.songLang, + key: delegate.key, + tempo: delegate.tempo, + sections: sections + ) + + let flatVerses = sections.enumerated().map { idx, sec in + SongImportVerse(label: sec.label, verseType: sec.type, + text: sec.lines.map { $0.text }.joined(separator: "\n"), order: idx) + } return SongImportResult( title: title, @@ -42,12 +74,42 @@ final class OpenLyricsImporter: SongImporter { tempo: delegate.tempo, songNumber: delegate.songNumber, tags: delegate.themes.joined(separator: ", "), - verses: delegate.verses + verses: flatVerses, + titles: aliases, + language: delegate.songLang, + themes: delegate.themes, + versions: [version] ) } + + private func classify(_ name: String, index: Int) -> (key: String, label: String, type: String) { + let lower = name.lowercased() + let num = name.filter { $0.isNumber } + if lower.hasPrefix("c") { + return (num.isEmpty ? "c" : "c\(num)", "Chorus\(num.isEmpty ? "" : " \(num)")", "chorus") + } else if lower.hasPrefix("b") { + return ("b\(num.isEmpty ? "1" : num)", "Bridge\(num.isEmpty ? "" : " \(num)")", "bridge") + } else if lower.hasPrefix("p") { + return ("p\(num.isEmpty ? "1" : num)", "Pre-Chorus", "prechorus") + } else if lower.hasPrefix("e") { + return ("e", "Ending", "ending") + } else if lower.hasPrefix("v") { + return ("v\(num.isEmpty ? "1" : num)", "Verse \(num.isEmpty ? "1" : num)", "verse") + } else { + return (name.isEmpty ? "s\(index)" : name, name.isEmpty ? "Verse \(index + 1)" : name, "other") + } + } } // MARK: - OpenLyrics XML Parser Delegate + +/// A verse keyed by name, holding lines per language. +private struct VerseGroup { + var name: String + var primaryLang: String = "" + var lines: [String: [SongLine]] = [:] // lang -> ordered lines +} + private final class OpenLyricsParserDelegate: NSObject, XMLParserDelegate { var titles: [String] = [] var authors: [String] = [] @@ -57,21 +119,22 @@ private final class OpenLyricsParserDelegate: NSObject, XMLParserDelegate { var tempo = "" var songNumber = "" var themes: [String] = [] - var verses: [SongImportVerse] = [] + var songLang = "" + + private(set) var orderedVerses: [VerseGroup] = [] + private var verseIndexByName: [String: Int] = [:] - private var currentElement = "" private var currentText = "" private var elementStack: [String] = [] // Verse parsing state private var currentVerseName = "" - private var currentVerseLines: [String] = [] - private var isInVerse = false + private var currentVerseLang = "" + private var currentLines: [SongLine] = [] + private var currentLineText = "" + private var currentLineChords: [SongChord] = [] private var isInLines = false - private var verseOrder = 0 - // Properties parsing - private var isInProperties = false private var isInTitles = false private var isInAuthors = false private var isInThemes = false @@ -84,49 +147,33 @@ private final class OpenLyricsParserDelegate: NSObject, XMLParserDelegate { attributes attributeDict: [String: String] = [:] ) { elementStack.append(elementName) - currentElement = elementName - currentText = "" switch elementName { - case "properties": - isInProperties = true - - case "titles": - isInTitles = true - - case "authors": - isInAuthors = true - - case "themes": - isInThemes = true - - case "title": - currentText = "" - - case "author": - currentText = "" - - case "theme": - currentText = "" - case "song": - // Root element - break - + if songLang.isEmpty { songLang = attributeDict["lang"] ?? "" } + case "titles": isInTitles = true + case "authors": isInAuthors = true + case "themes": isInThemes = true + case "title", "author", "theme", "copyright", "ccliNo", "key", "tempo", "songNumber", "number": + currentText = "" case "verse": - isInVerse = true - currentVerseName = attributeDict["name"] ?? "v\(verseOrder + 1)" - currentVerseLines = [] - + currentVerseName = attributeDict["name"] ?? "v\(orderedVerses.count + 1)" + currentVerseLang = attributeDict["lang"] ?? "" case "lines": isInLines = true - currentText = "" - + currentLines = [] + currentLineText = "" + currentLineChords = [] case "br": + if isInLines { finalizeLine() } + case "chord": if isInLines { - currentText += "\n" + let sym = attributeDict["name"] + ?? [attributeDict["root"], attributeDict["structure"]].compactMap { $0 }.joined() + if !sym.isEmpty { + currentLineChords.append(SongChord(sym: sym, pos: currentLineText.count)) + } } - default: break } @@ -134,6 +181,7 @@ private final class OpenLyricsParserDelegate: NSObject, XMLParserDelegate { func parser(_ parser: XMLParser, foundCharacters string: String) { currentText += string + if isInLines { currentLineText += string } } func parser( @@ -145,103 +193,51 @@ private final class OpenLyricsParserDelegate: NSObject, XMLParserDelegate { let trimmed = currentText.trimmingCharacters(in: .whitespacesAndNewlines) switch elementName { - case "properties": - isInProperties = false - - case "titles": - isInTitles = false - - case "authors": - isInAuthors = false - - case "themes": - isInThemes = false - - case "title": - if isInTitles && !trimmed.isEmpty { - titles.append(trimmed) - } - - case "author": - if isInAuthors && !trimmed.isEmpty { - authors.append(trimmed) - } - - case "theme": - if isInThemes && !trimmed.isEmpty { - themes.append(trimmed) - } - - case "copyright": - copyright = trimmed - - case "ccliNo": - ccliNumber = trimmed - - case "key": - key = trimmed - - case "tempo": - tempo = trimmed - - case "songNumber", "number": - songNumber = trimmed - + case "titles": isInTitles = false + case "authors": isInAuthors = false + case "themes": isInThemes = false + case "title": if isInTitles && !trimmed.isEmpty { titles.append(trimmed) } + case "author": if isInAuthors && !trimmed.isEmpty { authors.append(trimmed) } + case "theme": if isInThemes && !trimmed.isEmpty { themes.append(trimmed) } + case "copyright": copyright = trimmed + case "ccliNo": ccliNumber = trimmed + case "key": key = trimmed + case "tempo": tempo = trimmed + case "songNumber", "number": songNumber = trimmed case "lines": if isInLines { - let lineText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) - if !lineText.isEmpty { - currentVerseLines.append(lineText) - } + finalizeLine() isInLines = false + storeCurrentVerseLines() } - - case "verse": - if isInVerse { - let fullText = currentVerseLines.joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - - if !fullText.isEmpty { - let (label, type) = classifyVerseName(currentVerseName) - verses.append(SongImportVerse( - label: label, - verseType: type, - text: fullText, - order: verseOrder - )) - verseOrder += 1 - } - isInVerse = false - } - default: break } - if !elementStack.isEmpty { - elementStack.removeLast() - } - currentElement = elementStack.last ?? "" + if !elementStack.isEmpty { elementStack.removeLast() } } - private func classifyVerseName(_ name: String) -> (label: String, type: String) { - let lower = name.lowercased() + private func finalizeLine() { + let text = currentLineText.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty || !currentLineChords.isEmpty { + currentLines.append(SongLine(text: text, chords: currentLineChords)) + } + currentLineText = "" + currentLineChords = [] + } - if lower.hasPrefix("v") { - let num = name.filter { $0.isNumber } - return ("Verse \(num.isEmpty ? "1" : num)", "verse") - } else if lower.hasPrefix("c") { - let num = name.filter { $0.isNumber } - return ("Chorus\(num.isEmpty ? "" : " \(num)")", "chorus") - } else if lower.hasPrefix("b") { - let num = name.filter { $0.isNumber } - return ("Bridge\(num.isEmpty ? "" : " \(num)")", "bridge") - } else if lower.hasPrefix("p") { - return ("Pre-Chorus", "pre-chorus") - } else if lower.hasPrefix("e") { - return ("Ending", "ending") + private func storeCurrentVerseLines() { + guard !currentLines.isEmpty else { return } + let lang = currentVerseLang + if let existing = verseIndexByName[currentVerseName] { + if orderedVerses[existing].primaryLang.isEmpty { orderedVerses[existing].primaryLang = lang } + orderedVerses[existing].lines[lang] = currentLines } else { - return (name, "other") + var group = VerseGroup(name: currentVerseName, primaryLang: lang) + group.lines[lang] = currentLines + verseIndexByName[currentVerseName] = orderedVerses.count + orderedVerses.append(group) } + currentLines = [] } } diff --git a/TopPresenter/Services/Import/OpenSongImporter.swift b/TopPresenter/Services/Import/OpenSongImporter.swift index 2df6c8c..75244f1 100644 --- a/TopPresenter/Services/Import/OpenSongImporter.swift +++ b/TopPresenter/Services/Import/OpenSongImporter.swift @@ -9,7 +9,9 @@ import Foundation /// Importer for OpenSong XML format songs. /// OpenSong uses one file per song, typically without a file extension. -/// Each song file contains lyrics with verse markers. +/// +/// Rich mapping: `.`-prefixed chord lines become positioned `SongChord`s on the following +/// lyric line, and the `<presentation>` order becomes the version `arrangement`. final class OpenSongImporter: SongImporter { let format: SupportedSongFormat = .openSongXML @@ -31,7 +33,34 @@ final class OpenSongImporter: SongImporter { } let title = delegate.title.isEmpty ? fileURL.lastPathComponent : delegate.title - let verses = parseLyrics(delegate.lyrics, presentation: delegate.presentation) + let sections = parseSections(delegate.lyrics) + let arrangement = mapPresentation(delegate.presentation, sections: sections) + + let version = SongImportVersion( + name: "Original", + key: delegate.key, + tempo: delegate.tempo, + arrangement: arrangement, + sections: sections + ) + + // Flat verses follow the arrangement when present (legacy presentation order). + let orderedSections: [SongImportSection] + if arrangement.isEmpty { + orderedSections = sections + } else { + let byKey = Dictionary(sections.map { ($0.sectionKey, $0) }, uniquingKeysWith: { a, _ in a }) + orderedSections = arrangement.compactMap { byKey[$0] } + } + let flatVerses = orderedSections.enumerated().map { idx, sec in + SongImportVerse(label: sec.label, verseType: sec.type, + text: sec.lines.map { $0.text }.joined(separator: "\n"), order: idx) + } + + let themes = delegate.theme + .components(separatedBy: CharacterSet(charactersIn: ";,/")) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } return SongImportResult( title: title, @@ -42,145 +71,139 @@ final class OpenSongImporter: SongImporter { tempo: delegate.tempo, songNumber: delegate.hymn_number, tags: delegate.theme, - verses: verses + verses: flatVerses, + themes: themes, + versions: [version] ) } - /// Parse OpenSong lyrics format. - /// Lines starting with [ mark verse sections (e.g., [V1], [C], [B]) - /// Lines starting with . are chord lines (ignored for presentation) - /// Lines starting with a space are lyric lines - private func parseLyrics(_ lyrics: String, presentation: String) -> [SongImportVerse] { - let lines = lyrics.components(separatedBy: .newlines) - var verses: [SongImportVerse] = [] - var currentLabel = "Verse 1" - var currentType = "verse" - var currentLines: [String] = [] - var order = 0 - - for line in lines { + /// Parse OpenSong lyrics into rich sections (chords kept as positions). + private func parseSections(_ lyrics: String) -> [SongImportSection] { + let rawLines = lyrics.components(separatedBy: .newlines) + var sections: [SongImportSection] = [] + var counters: [String: Int] = [:] + + var curType = "verse" + var curLabel = "Verse 1" + var curKey = "v1" + var curLines: [SongLine] = [] + var pendingChords: [SongChord] = [] + var started = false + + func flush() { + guard !curLines.isEmpty else { return } + sections.append(SongImportSection(sectionKey: curKey, type: curType, label: curLabel, order: sections.count, lines: curLines)) + curLines = [] + } + + for line in rawLines { if line.hasPrefix("[") { - // Save previous verse - if !currentLines.isEmpty { - let text = currentLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - verses.append(SongImportVerse( - label: currentLabel, - verseType: currentType, - text: text, - order: order - )) - order += 1 - } - currentLines = [] - } - - // Parse section header - let sectionTag = line - .replacingOccurrences(of: "[", with: "") + flush() + let tag = line.replacingOccurrences(of: "[", with: "") .replacingOccurrences(of: "]", with: "") .trimmingCharacters(in: .whitespaces) - - let (label, type) = classifySection(sectionTag) - currentLabel = label - currentType = type - + let classified = classifySection(tag, counters: &counters) + curType = classified.type + curLabel = classified.label + curKey = classified.key + pendingChords = [] + started = true } else if line.hasPrefix(".") { - // Chord line - skip for lyrics - continue - } else if line.hasPrefix(" ") || line.hasPrefix(";") { - // Lyric line (remove leading space) or comment - let lyricLine: String - if line.hasPrefix(";") { - continue // Skip comments - } else { - lyricLine = String(line.dropFirst()) - } - currentLines.append(lyricLine) + pendingChords = parseChordLine(line) + } else if line.hasPrefix(";") { + continue // comment + } else if line.hasPrefix(" ") { + let text = String(line.dropFirst()) + curLines.append(makeLine(text: text, chords: pendingChords)) + pendingChords = [] } else if !line.trimmingCharacters(in: .whitespaces).isEmpty { - // Some OpenSong files have lyrics without leading space - currentLines.append(line) + curLines.append(makeLine(text: line, chords: pendingChords)) + pendingChords = [] } } + flush() - // Save last verse - if !currentLines.isEmpty { - let text = currentLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - verses.append(SongImportVerse( - label: currentLabel, - verseType: currentType, - text: text, - order: order - )) - } + // Some files have no section markers at all — keep one verse. + if sections.isEmpty && started == false && !curLines.isEmpty { + sections.append(SongImportSection(sectionKey: "v1", type: "verse", label: "Verse 1", order: 0, lines: curLines)) } + return sections + } - // If presentation order is specified, reorder - if !presentation.isEmpty { - return applyPresentationOrder(verses: verses, presentation: presentation) - } + private func makeLine(text: String, chords: [SongChord]) -> SongLine { + guard !chords.isEmpty else { return SongLine(text: text) } + let clamped = chords.map { SongChord(sym: $0.sym, pos: min(max($0.pos, 0), text.count)) } + return SongLine(text: text, chords: clamped) + } - return verses + /// Parse a `.`-prefixed chord line into chords at column positions (column 0 == after the dot). + private func parseChordLine(_ line: String) -> [SongChord] { + let chars = Array(line) + var chords: [SongChord] = [] + var i = 1 // skip leading "." + while i < chars.count { + if chars[i] == " " { i += 1; continue } + var sym = "" + let start = i + while i < chars.count, chars[i] != " " { + sym.append(chars[i]); i += 1 + } + if !sym.isEmpty { chords.append(SongChord(sym: sym, pos: max(start - 1, 0))) } + } + return chords } - private func classifySection(_ tag: String) -> (label: String, type: String) { + private func classifySection(_ tag: String, counters: inout [String: Int]) -> (key: String, label: String, type: String) { let upper = tag.uppercased() + let num = tag.filter { $0.isNumber } - if upper.hasPrefix("V") || upper.hasPrefix("VERSE") { - let num = tag.filter { $0.isNumber } - return ("Verse \(num.isEmpty ? "1" : num)", "verse") - } else if upper.hasPrefix("C") || upper.hasPrefix("CHORUS") { - let num = tag.filter { $0.isNumber } - return ("Chorus\(num.isEmpty ? "" : " \(num)")", "chorus") - } else if upper.hasPrefix("B") || upper.hasPrefix("BRIDGE") { - let num = tag.filter { $0.isNumber } - return ("Bridge\(num.isEmpty ? "" : " \(num)")", "bridge") - } else if upper.hasPrefix("P") || upper.hasPrefix("PRE") { - return ("Pre-Chorus", "pre-chorus") - } else if upper.hasPrefix("T") || upper.hasPrefix("TAG") { - return ("Tag", "tag") - } else if upper.hasPrefix("E") || upper.hasPrefix("END") { - return ("Ending", "ending") - } else { - return (tag, "other") + func nextKey(_ prefix: String) -> Int { + let n = (counters[prefix] ?? 0) + 1 + counters[prefix] = n + return n } - } - private func applyPresentationOrder(verses: [SongImportVerse], presentation: String) -> [SongImportVerse] { - let sections = presentation.components(separatedBy: .whitespaces).filter { !$0.isEmpty } - var ordered: [SongImportVerse] = [] - var orderIndex = 0 - - for section in sections { - // Find matching verse by tag - if let match = verses.first(where: { matchesTag($0, sectionTag: section) }) { - ordered.append(SongImportVerse( - label: match.label, - verseType: match.verseType, - text: match.text, - order: orderIndex - )) - orderIndex += 1 - } + if upper.hasPrefix("V") { + let n = num.isEmpty ? "\(nextKey("v"))" : num + return ("v\(n)", "Verse \(n)", "verse") + } else if upper.hasPrefix("C") { + let n = num.isEmpty ? nextKey("c") : (Int(num) ?? 1) + return (n == 1 ? "c" : "c\(n)", n == 1 ? "Chorus" : "Chorus \(n)", "chorus") + } else if upper.hasPrefix("B") { + let n = num.isEmpty ? nextKey("b") : (Int(num) ?? 1) + return ("b\(n)", n == 1 ? "Bridge" : "Bridge \(n)", "bridge") + } else if upper.hasPrefix("P") { + return ("p\(nextKey("p"))", "Pre-Chorus", "prechorus") + } else if upper.hasPrefix("T") { + return ("t\(nextKey("t"))", "Tag", "tag") + } else if upper.hasPrefix("E") { + return ("e", "Ending", "ending") + } else { + return (tag.isEmpty ? "s\(counters.count)" : tag, tag, "other") } - - return ordered.isEmpty ? verses : ordered } - private func matchesTag(_ verse: SongImportVerse, sectionTag: String) -> Bool { - let tag = sectionTag.uppercased() - let label = verse.label.uppercased() - - if tag.hasPrefix("V") { - let num = tag.filter { $0.isNumber } - return label.contains("VERSE") && (num.isEmpty || label.contains(num)) - } else if tag.hasPrefix("C") { - return label.contains("CHORUS") - } else if tag.hasPrefix("B") { - return label.contains("BRIDGE") + /// Map an OpenSong `<presentation>` string (e.g. "V1 C V2 C B") to section keys. + private func mapPresentation(_ presentation: String, sections: [SongImportSection]) -> [String] { + let tokens = presentation.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return [] } + var keys: [String] = [] + for token in tokens { + let upper = token.uppercased() + let num = token.filter { $0.isNumber } + let match = sections.first { sec in + let label = sec.label.uppercased() + if upper.hasPrefix("V") { return sec.type == "verse" && (num.isEmpty || label.contains(num)) } + if upper.hasPrefix("C") { return sec.type == "chorus" } + if upper.hasPrefix("B") { return sec.type == "bridge" } + if upper.hasPrefix("P") { return sec.type == "prechorus" } + if upper.hasPrefix("T") { return sec.type == "tag" } + if upper.hasPrefix("E") { return sec.type == "ending" } + return false + } + if let match { keys.append(match.sectionKey) } } - return false + return keys } } diff --git a/TopPresenter/Services/Import/PlainTextSongImporter.swift b/TopPresenter/Services/Import/PlainTextSongImporter.swift new file mode 100644 index 0000000..9d83ace --- /dev/null +++ b/TopPresenter/Services/Import/PlainTextSongImporter.swift @@ -0,0 +1,94 @@ +// +// PlainTextSongImporter.swift +// TopPresenter +// +// Created by Cosmin Calin on 17/06/2026. +// + +import Foundation + +/// Importer for plain-text songs (.txt): blank-line-separated stanzas become sections. +/// A stanza whose first line is a bracketed/keyword label (e.g. "[Chorus]", "Refren:") +/// is typed accordingly; everything else is a verse. +final class PlainTextSongImporter: SongImporter { + let format: SupportedSongFormat = .plainText + + func parse(fileURL: URL) async throws -> SongImportResult { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw SongImportError.fileNotFound + } + let data = try Data(contentsOf: fileURL) + guard !data.isEmpty else { throw SongImportError.emptyFile } + guard let content = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) else { + throw SongImportError.parsingFailed("Unreadable text encoding") + } + return Self.parse(content: content, fallbackTitle: fileURL.deletingPathExtension().lastPathComponent) + } + + static func parse(content: String, fallbackTitle: String) -> SongImportResult { + let normalized = content.replacingOccurrences(of: "\r\n", with: "\n") + let blocks = normalized.components(separatedBy: "\n\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var sections: [SongImportSection] = [] + var counters: [String: Int] = [:] + + for block in blocks { + var lines = block.components(separatedBy: "\n") + var type = "verse" + var explicitLabel = "" + + if let first = lines.first, let detected = detectLabel(first) { + type = detected.type + explicitLabel = detected.label + lines.removeFirst() + } + let songLines = lines + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) } + .filter { !$0.isEmpty } + .map { SongLine(text: $0) } + guard !songLines.isEmpty else { continue } + + let n = (counters[type] ?? 0) + 1 + counters[type] = n + let (key, label) = keyAndLabel(type: type, n: n, explicit: explicitLabel) + sections.append(SongImportSection(sectionKey: key, type: type, label: label, order: sections.count, lines: songLines)) + } + + let version = SongImportVersion(name: "Original", source: "Plain Text", sections: sections) + let title = fallbackTitle.isEmpty ? "Untitled" : fallbackTitle + + return SongImportResult( + title: title, author: "", copyright: "", ccliNumber: "", key: "", tempo: "", + songNumber: "", tags: "", verses: [], versions: [version] + ) + } + + private static func detectLabel(_ raw: String) -> (type: String, label: String)? { + var s = raw.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty else { return nil } + // Strip [ ] or trailing : + if s.hasPrefix("["), s.hasSuffix("]") { s = String(s.dropFirst().dropLast()) } + s = s.trimmingCharacters(in: CharacterSet(charactersIn: ": ")) + let lower = s.lowercased() + // Only treat short tokens as labels (avoid eating a real lyric line). + guard s.count <= 24 else { return nil } + if lower.hasPrefix("refren") || lower.hasPrefix("chorus") || lower.hasPrefix("cor") { return ("chorus", "Chorus") } + if lower.hasPrefix("punte") || lower.hasPrefix("bridge") { return ("bridge", "Bridge") } + if lower.hasPrefix("pre") { return ("prechorus", "Pre-Chorus") } + if lower.hasPrefix("strofa") || lower.hasPrefix("verse") || lower.hasPrefix("v.") { return ("verse", s) } + if lower.hasPrefix("final") || lower.hasPrefix("ending") { return ("ending", "Ending") } + return nil + } + + private static func keyAndLabel(type: String, n: Int, explicit: String) -> (String, String) { + switch type { + case "chorus": return (n == 1 ? "c" : "c\(n)", explicit.isEmpty ? (n == 1 ? "Chorus" : "Chorus \(n)") : explicit) + case "bridge": return ("b\(n)", explicit.isEmpty ? "Bridge" : explicit) + case "prechorus": return ("p\(n)", "Pre-Chorus") + case "ending": return ("e", "Ending") + default: return ("v\(n)", explicit.isEmpty ? "Verse \(n)" : explicit) + } + } +} diff --git a/TopPresenter/Services/Import/PowerPointSongImporter.swift b/TopPresenter/Services/Import/PowerPointSongImporter.swift index 13be75b..c828505 100644 --- a/TopPresenter/Services/Import/PowerPointSongImporter.swift +++ b/TopPresenter/Services/Import/PowerPointSongImporter.swift @@ -372,23 +372,31 @@ final class PowerPointSongImporter: SongImporter { } /// Build a SongImportResult from extracted slide data. + /// + /// PowerPoint has no real structure, so each slide becomes a section. We additionally: + /// - prefer the file name as the song title (slide 1 is usually a lyric, not a title); + /// - collapse identical repeated slides into a single section referenced multiple times + /// in the version `arrangement` (a slide repeated ≥2× is treated as the chorus); + /// - guess the language from the lyrics' diacritics. private func buildSongResult( fileName: String, presentationTitle: String, slides: [(title: String, body: String)] ) -> SongImportResult { - // Use presentation title, or first slide title, or filename let songTitle: String if !presentationTitle.isEmpty { songTitle = presentationTitle - } else if let firstSlide = slides.first, !firstSlide.title.isEmpty { - songTitle = firstSlide.title - } else { + } else if !fileName.isEmpty { songTitle = fileName + } else { + songTitle = slides.first?.title ?? "Untitled" } - var verses: [SongImportVerse] = [] - for (index, slide) in slides.enumerated() { + // Collect non-empty slide units. + struct Unit { let text: String; let normKey: String; let labeledType: String? } + var units: [Unit] = [] + var allText = "" + for slide in slides { let fullText: String if !slide.title.isEmpty && !slide.body.isEmpty { fullText = slide.title + "\n" + slide.body @@ -397,37 +405,48 @@ final class PowerPointSongImporter: SongImporter { } else { fullText = slide.body } - guard !fullText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + allText += " " + fullText + units.append(Unit(text: fullText, normKey: normalize(fullText), labeledType: labeledType(slide.title))) + } + + var repeatCount: [String: Int] = [:] + for unit in units { repeatCount[unit.normKey, default: 0] += 1 } - // Try to detect verse type from the text or label - let label: String - let verseType: String - let lowerTitle = slide.title.lowercased() - - if lowerTitle.hasPrefix("chorus") || lowerTitle.hasPrefix("refren") || lowerTitle.hasPrefix("cor") { - label = "Chorus" - verseType = "chorus" - } else if lowerTitle.hasPrefix("bridge") || lowerTitle.hasPrefix("punte") { - label = "Bridge" - verseType = "bridge" - } else if lowerTitle.hasPrefix("pre-chorus") || lowerTitle.hasPrefix("pre chorus") { - label = "Pre-Chorus" - verseType = "pre-chorus" - } else if lowerTitle.hasPrefix("ending") || lowerTitle.hasPrefix("final") { - label = "Ending" - verseType = "ending" + var sections: [SongImportSection] = [] + var keyByNorm: [String: String] = [:] + var arrangement: [String] = [] + var counters: [String: Int] = [:] + + for unit in units { + if let existing = keyByNorm[unit.normKey] { + arrangement.append(existing) // repeated slide → reuse the section + continue + } + let type: String + if let labeled = unit.labeledType { + type = labeled + } else if (repeatCount[unit.normKey] ?? 0) >= 2 { + type = "chorus" // an unlabeled slide that repeats is the chorus } else { - label = "Slide \(index + 1)" - verseType = "verse" + type = "verse" } + let (key, label) = nextKeyLabel(type, &counters) + let lines = unit.text.components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) } + .map { SongLine(text: $0) } + sections.append(SongImportSection(sectionKey: key, type: type, label: label, order: sections.count, lines: lines)) + keyByNorm[unit.normKey] = key + arrangement.append(key) + } + + // Only record an arrangement when slides actually repeat (otherwise it's just section order). + let finalArrangement = arrangement.count > sections.count ? arrangement : [] + let version = SongImportVersion(name: "Original", arrangement: finalArrangement, sections: sections) - verses.append(SongImportVerse( - label: label, - verseType: verseType, - text: fullText, - order: index - )) + let flatVerses = sections.enumerated().map { idx, sec in + SongImportVerse(label: sec.label, verseType: sec.type, + text: sec.lines.map { $0.text }.joined(separator: "\n"), order: idx) } return SongImportResult( @@ -439,9 +458,48 @@ final class PowerPointSongImporter: SongImporter { tempo: "", songNumber: "", tags: "powerpoint", - verses: verses + verses: flatVerses, + language: guessLanguage(allText), + versions: [version] ) } + + private func normalize(_ text: String) -> String { + text.lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private func labeledType(_ title: String) -> String? { + let t = title.lowercased() + if t.hasPrefix("chorus") || t.hasPrefix("refren") || t.hasPrefix("cor") { return "chorus" } + if t.hasPrefix("bridge") || t.hasPrefix("punte") { return "bridge" } + if t.hasPrefix("pre-chorus") || t.hasPrefix("pre chorus") { return "prechorus" } + if t.hasPrefix("ending") || t.hasPrefix("final") { return "ending" } + return nil + } + + private func nextKeyLabel(_ type: String, _ counters: inout [String: Int]) -> (key: String, label: String) { + let n = (counters[type] ?? 0) + 1 + counters[type] = n + switch type { + case "chorus": return (n == 1 ? "c" : "c\(n)", n == 1 ? "Chorus" : "Chorus \(n)") + case "bridge": return ("b\(n)", n == 1 ? "Bridge" : "Bridge \(n)") + case "prechorus": return ("p\(n)", "Pre-Chorus") + case "ending": return ("e", "Ending") + default: return ("v\(n)", "Verse \(n)") + } + } + + private func guessLanguage(_ text: String) -> String { + let lower = text.lowercased() + let roDiacritics = lower.reduce(0) { "ăâîșț".contains($1) ? $0 + 1 : $0 } + let esMarks = lower.reduce(0) { "ñ¿¡".contains($1) ? $0 + 1 : $0 } + if roDiacritics >= 3 { return "ro" } + if esMarks >= 1 { return "es" } + return "" + } } // MARK: - PPTX Slide XML Parser diff --git a/TopPresenter/Services/Import/SongImportProtocol.swift b/TopPresenter/Services/Import/SongImportProtocol.swift index 5ee1d23..b7ca003 100644 --- a/TopPresenter/Services/Import/SongImportProtocol.swift +++ b/TopPresenter/Services/Import/SongImportProtocol.swift @@ -47,7 +47,12 @@ extension SongImporter { } } -/// Result of a song import operation +/// Result of a song import operation. +/// +/// Back-compatible: the legacy flat fields + `verses` are still the minimum an importer +/// must produce. Richer importers (v2 JSON, OpenLyrics, ChordPro…) additionally populate +/// `versions` and the metadata fields; when `versions` is empty, `ImportService` synthesizes +/// a single "Original" version from `verses`. struct SongImportResult { let title: String let author: String @@ -58,6 +63,19 @@ struct SongImportResult { let songNumber: String let tags: String let verses: [SongImportVerse] + + // v2 additive (all defaulted so existing importers compile unchanged) + var titles: [String] = [] + var language: String = "" + var themes: [String] = [] + var style: String = "" + var songbook: SongImportSongbook? = nil + var authorWords: String = "" + var authorMusic: String = "" + var authorTranslation: String = "" + var notes: String = "" + var media: [SongImportMedia] = [] + var versions: [SongImportVersion] = [] } struct SongImportVerse { @@ -67,6 +85,113 @@ struct SongImportVerse { let order: Int } +// MARK: - v2 rich import structures + +// Reference type on purpose: as a value type with this many fields, copying +// `Optional<SongImportVersion>` hit a miscompiled outlined value-witness (segfault). +final class SongImportVersion { + var name: String + var displayTitle: String + var author: String + var titles: [String] + var authorWords: String + var authorMusic: String + var authorTranslation: String + var style: String + var songbookNumber: String + var songbookName: String + var themes: [String] + var notes: String + var language: String + var key: String + var capo: Int + var tempo: String + var timeSignature: String + var copyright: String + var ccliNumber: String + var source: String + var repeatStyle: String + var overridesMetadata: Bool + var arrangement: [String] + var sections: [SongImportSection] + + init( + name: String = "", + displayTitle: String = "", + author: String = "", + titles: [String] = [], + authorWords: String = "", + authorMusic: String = "", + authorTranslation: String = "", + style: String = "", + songbookNumber: String = "", + songbookName: String = "", + themes: [String] = [], + notes: String = "", + language: String = "", + key: String = "", + capo: Int = 0, + tempo: String = "", + timeSignature: String = "", + copyright: String = "", + ccliNumber: String = "", + source: String = "", + repeatStyle: String = "", + overridesMetadata: Bool = false, + arrangement: [String] = [], + sections: [SongImportSection] = [] + ) { + self.name = name + self.displayTitle = displayTitle + self.author = author + self.titles = titles + self.authorWords = authorWords + self.authorMusic = authorMusic + self.authorTranslation = authorTranslation + self.style = style + self.songbookNumber = songbookNumber + self.songbookName = songbookName + self.themes = themes + self.notes = notes + self.language = language + self.key = key + self.capo = capo + self.tempo = tempo + self.timeSignature = timeSignature + self.copyright = copyright + self.ccliNumber = ccliNumber + self.source = source + self.repeatStyle = repeatStyle + self.overridesMetadata = overridesMetadata + self.arrangement = arrangement + self.sections = sections + } +} + +struct SongImportSection { + var sectionKey: String + var type: String + var label: String + var order: Int + var repeatCount: Int = 1 + var lines: [SongLine] = [] // SongLine carries chords + bilingual translations +} + +struct SongImportSongbook { + var name: String + var publisher: String = "" + var language: String = "" + var year: String = "" + var number: String = "" +} + +struct SongImportMedia { + var role: String + var kind: String + var filename: String + var bookmark: String? = nil +} + /// Errors that can occur during song import enum SongImportError: LocalizedError { case fileNotFound diff --git a/TopPresenter/Services/Import/TopPresenterSongImporter.swift b/TopPresenter/Services/Import/TopPresenterSongImporter.swift new file mode 100644 index 0000000..1ede379 --- /dev/null +++ b/TopPresenter/Services/Import/TopPresenterSongImporter.swift @@ -0,0 +1,249 @@ +// +// TopPresenterSongImporter.swift +// TopPresenter +// +// Created by Cosmin Calin on 17/06/2026. +// +// ───────────────────────────────────────────────────────────────────────────── +// TopPresenter Song JSON — the "GOAT" song format (authoritative spec) +// ───────────────────────────────────────────────────────────────────────────── +// A SINGLE-SONG document: one JSON file = one song (carrying all its versions). +// The ResurseCrestine userscript emits one file per song; TopPresenter imports AND +// exports this exact format. Bulk = a folder of these files. schemaVersion "2.0.0". +// +// { +// "schemaVersion": "2.0.0", +// "format": "TopPresenter Song", +// "exportInfo": { "source", "exportDate", "exporterVersion" }, +// "song": { +// "title": "...", +// "titles": ["alias", ...], +// "language": "ro", +// "themes": ["worship", ...], +// "style": "imn|coral|contemporan|...", +// "songbook": { "name", "publisher", "language", "year", "number" } | null, +// "authorWords", "authorMusic", "authorTranslation", "copyright", "ccliNumber", "notes", +// "media": [ { "role", "kind", "filename", "bookmark"? } ], +// "versions": [{ +// "name", "language", "key", "capo", "tempo", "timeSignature", +// "copyright"?, "ccliNumber"?, "source"?, +// "arrangement": ["v1","c","v2","c","b","c"], +// "sections": [{ +// "id": "v1", "type": "verse|chorus|bridge|prechorus|intro|ending|tag|interlude", +// "label": "Strofa 1", "order": 0, +// "lines": [{ "text", "chords": [{"sym","pos"}], "translations": {"es":"...","en":"..."} }], +// "_extensions": {} +// }], +// "_extensions": {} +// }], +// "_extensions": {} +// } +// } +// +// Back-compat: legacy v1 "TopPresenter Songs" bundle files (root `songs` array of +// flat {title, verses:[{label,verseType,text,order}]} objects) are also accepted. +// ───────────────────────────────────────────────────────────────────────────── + +import Foundation + +/// Importer for the native TopPresenter Song JSON format (single song per file). +final class TopPresenterSongImporter: SongImporter { + let format: SupportedSongFormat = .topPresenterJSON + + func parse(fileURL: URL) async throws -> SongImportResult { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw SongImportError.fileNotFound + } + let data = try Data(contentsOf: fileURL) + guard !data.isEmpty else { throw SongImportError.emptyFile } + + let results = try Self.allResults(from: data, fallbackTitle: fileURL.deletingPathExtension().lastPathComponent) + guard let first = results.first else { throw SongImportError.noSongsFound } + return first + } + + /// Every song in a file: 1 for a single-song doc, N for a legacy bundle. + static func allResults(from data: Data, fallbackTitle: String = "") throws -> [SongImportResult] { + guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + throw SongImportError.invalidFormat("Not valid JSON") + } + + // Single-song v2 document + if let songObj = root["song"] as? [String: Any] { + return [parseSong(songObj, fallbackTitle: fallbackTitle)] + } + // Bundle / legacy v1 collection + if let songs = root["songs"] as? [[String: Any]] { + return songs.map { parseSong($0, fallbackTitle: fallbackTitle) } + } + // A bare song object (no envelope) + if root["title"] != nil || root["versions"] != nil { + return [parseSong(root, fallbackTitle: fallbackTitle)] + } + throw SongImportError.invalidFormat("No song or songs key") + } + + // MARK: - Song parsing + + private static func parseSong(_ obj: [String: Any], fallbackTitle: String) -> SongImportResult { + let titles = (obj["titles"] as? [String]) ?? [] + let title = (obj["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? titles.first + ?? (fallbackTitle.isEmpty ? "Untitled" : fallbackTitle) + + let themes = (obj["themes"] as? [String]) ?? [] + let tags = (obj["tags"] as? String) ?? themes.joined(separator: ", ") + + // Authors: prefer split fields, fall back to a combined "author". + let authorWords = (obj["authorWords"] as? String) ?? "" + let authorMusic = (obj["authorMusic"] as? String) ?? "" + let authorTranslation = (obj["authorTranslation"] as? String) ?? "" + let combinedAuthor = (obj["author"] as? String) + ?? [authorWords, authorMusic, authorTranslation].filter { !$0.isEmpty }.joined(separator: ", ") + + var songbook: SongImportSongbook? + if let sb = obj["songbook"] as? [String: Any], let name = sb["name"] as? String, !name.isEmpty { + songbook = SongImportSongbook( + name: name, + publisher: (sb["publisher"] as? String) ?? "", + language: (sb["language"] as? String) ?? "", + year: stringValue(sb["year"]), + number: stringValue(sb["number"]) + ) + } + + let media: [SongImportMedia] = (obj["media"] as? [[String: Any]] ?? []).compactMap { m in + guard let filename = m["filename"] as? String, !filename.isEmpty else { return nil } + return SongImportMedia( + role: (m["role"] as? String) ?? "audio", + kind: (m["kind"] as? String) ?? "audio", + filename: filename, + bookmark: m["bookmark"] as? String + ) + } + + // Versions (v2). If absent, synthesize one from legacy flat `verses`. + var versions: [SongImportVersion] = [] + if let versionsArr = obj["versions"] as? [[String: Any]] { + versions = versionsArr.enumerated().map { parseVersion($0.element, fallbackOrder: $0.offset) } + } + + var flatVerses: [SongImportVerse] = [] + if versions.isEmpty { + flatVerses = parseFlatVerses(obj["verses"]) + } else if let firstSections = versions.first?.sections { + // Provide a flat-verse view of the active version for legacy consumers. + flatVerses = firstSections.enumerated().map { idx, sec in + SongImportVerse(label: sec.label, verseType: sec.type, + text: sec.lines.map { $0.text }.joined(separator: "\n"), order: idx) + } + } + + let songNumber = stringValue(obj["songNumber"]) .isEmpty ? (songbook?.number ?? "") : stringValue(obj["songNumber"]) + + return SongImportResult( + title: title, + author: combinedAuthor, + copyright: (obj["copyright"] as? String) ?? "", + ccliNumber: stringValue(obj["ccliNumber"]), + key: (obj["key"] as? String) ?? versions.first?.key ?? "", + tempo: stringValue(obj["tempo"]), + songNumber: songNumber, + tags: tags, + verses: flatVerses, + titles: titles, + language: (obj["language"] as? String) ?? "", + themes: themes, + style: (obj["style"] as? String) ?? "", + songbook: songbook, + authorWords: authorWords, + authorMusic: authorMusic, + authorTranslation: authorTranslation, + notes: (obj["notes"] as? String) ?? "", + media: media, + versions: versions + ) + } + + private static func parseVersion(_ obj: [String: Any], fallbackOrder: Int) -> SongImportVersion { + let sectionsArr = obj["sections"] as? [[String: Any]] ?? [] + let sections: [SongImportSection] = sectionsArr.enumerated().map { idx, s in + let key = (s["id"] as? String) ?? (s["sectionKey"] as? String) ?? "s\(idx)" + return SongImportSection( + sectionKey: key, + type: (s["type"] as? String) ?? "verse", + label: (s["label"] as? String) ?? key, + order: (s["order"] as? Int) ?? idx, + repeatCount: (s["repeat"] as? Int) ?? (s["repeatCount"] as? Int) ?? 1, + lines: parseLines(s["lines"]) + ) + } + return SongImportVersion( + name: (obj["name"] as? String) ?? "", + displayTitle: (obj["displayTitle"] as? String) ?? (obj["title"] as? String) ?? "", + author: (obj["author"] as? String) ?? "", + titles: (obj["titles"] as? [String]) ?? [], + authorWords: (obj["authorWords"] as? String) ?? "", + authorMusic: (obj["authorMusic"] as? String) ?? "", + authorTranslation: (obj["authorTranslation"] as? String) ?? "", + style: (obj["style"] as? String) ?? "", + songbookNumber: stringValue(obj["songbookNumber"]), + songbookName: ((obj["songbook"] as? [String: Any])?["name"] as? String) ?? (obj["songbook"] as? String) ?? "", + themes: (obj["themes"] as? [String]) ?? [], + notes: (obj["notes"] as? String) ?? "", + language: (obj["language"] as? String) ?? "", + key: (obj["key"] as? String) ?? "", + capo: (obj["capo"] as? Int) ?? 0, + tempo: stringValue(obj["tempo"]), + timeSignature: (obj["timeSignature"] as? String) ?? "", + copyright: (obj["copyright"] as? String) ?? "", + ccliNumber: stringValue(obj["ccliNumber"]), + source: (obj["source"] as? String) ?? "", + repeatStyle: (obj["repeatStyle"] as? String) ?? "", + overridesMetadata: (obj["overridesMetadata"] as? Bool) ?? false, + arrangement: (obj["arrangement"] as? [String]) ?? [], + sections: sections + ) + } + + private static func parseLines(_ raw: Any?) -> [SongLine] { + // Rich line objects + if let arr = raw as? [[String: Any]] { + return arr.map { lo in + let chords: [SongChord] = (lo["chords"] as? [[String: Any]] ?? []).compactMap { c in + guard let sym = c["sym"] as? String else { return nil } + return SongChord(sym: sym, pos: (c["pos"] as? Int) ?? 0) + } + let translations = (lo["translations"] as? [String: String]) ?? [:] + return SongLine(text: (lo["text"] as? String) ?? "", chords: chords, translations: translations) + } + } + // Plain string lines + if let arr = raw as? [String] { + return arr.map { SongLine(text: $0) } + } + return [] + } + + private static func parseFlatVerses(_ raw: Any?) -> [SongImportVerse] { + guard let arr = raw as? [[String: Any]] else { return [] } + return arr.enumerated().map { idx, v in + SongImportVerse( + label: (v["label"] as? String) ?? "Slide \(idx + 1)", + verseType: (v["verseType"] as? String) ?? "verse", + text: (v["text"] as? String) ?? "", + order: (v["order"] as? Int) ?? idx + ) + } + } + + /// Coerce numbers/strings to String (JSON may carry year/ccli/tempo as either). + private static func stringValue(_ any: Any?) -> String { + switch any { + case let s as String: return s + case let i as Int: return String(i) + case let d as Double: return d == d.rounded() ? String(Int(d)) : String(d) + default: return "" + } + } +} diff --git a/TopPresenter/TopPresenterApp.swift b/TopPresenter/TopPresenterApp.swift index 41eb486..f9befd4 100644 --- a/TopPresenter/TopPresenterApp.swift +++ b/TopPresenter/TopPresenterApp.swift @@ -27,16 +27,19 @@ struct TopPresenterApp: App { } var sharedModelContainer: ModelContainer = { - let schema = Schema(versionedSchema: SchemaV1.self) + let schema = Schema(versionedSchema: SchemaV2.self) let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false ) do { + // No staged migration plan: the SchemaV1→V2 change is purely additive + // (new song entities + new Song attributes). SwiftData's automatic lightweight + // inference handles adding entities/relationships, which the staged + // `.lightweight`/`.custom` APIs reject at stage construction. return try ModelContainer( for: schema, - migrationPlan: TopPresenterMigrationPlan.self, configurations: [modelConfiguration] ) } catch { diff --git a/TopPresenter/Views/Main/Panels/SongsPreviewPanel.swift b/TopPresenter/Views/Main/Panels/SongsPreviewPanel.swift index e60c5c2..6bd341c 100644 --- a/TopPresenter/Views/Main/Panels/SongsPreviewPanel.swift +++ b/TopPresenter/Views/Main/Panels/SongsPreviewPanel.swift @@ -19,11 +19,15 @@ struct SongsPreviewPanel: View { Divider() - // Preview display — previews the selected song verse before it goes live + // Rendered preview of the slide selected in the filmstrip (falls back to the verse). PresentationPreviewCard(formatHint: "song", pendingContent: .init( - text: libraryManager.selectedSongVerse?.text ?? "", + text: libraryManager.songSlideText.isEmpty + ? (libraryManager.selectedSongVerse?.text ?? "") + : libraryManager.songSlideText, reference: libraryManager.selectedSong?.title ?? "", - subtitle: libraryManager.selectedSongVerse?.label ?? "" + subtitle: libraryManager.songSlideLabel.isEmpty + ? (libraryManager.selectedSongVerse?.label ?? "") + : libraryManager.songSlideLabel )) .padding() @@ -51,7 +55,10 @@ struct SongsPreviewPanel: View { Divider() } - Spacer() + // Song + output quick settings (parity with the Bible presenter sidebar) + StyleQuickSettings(sections: [.songOptions, .output]) + + Spacer(minLength: 0) Divider() @@ -125,34 +132,32 @@ struct SongVerseControlsBar: View { .disabled(!canNavigate(direction: -1)) .keyboardShortcut(.leftArrow, modifiers: []) - // Show / Hide toggle + // Show (explicit) — projects the selected slide/verse Button { - if isLive { - pm.clearOutput() - } else if let verse = currentVerse, let song = currentSong { - pm.showSongVerse( - text: verse.text, - title: song.title, - verseLabel: verse.label, - slideIndex: currentIndex ?? 0, - slideCount: sortedVerses.count - ) - } + showCurrent() } label: { - Label( - isLive - ? String(localized: "Hide", comment: "Control button") - : String(localized: "Show", comment: "Control button"), - systemImage: isLive ? "eye.slash.fill" : "play.fill" - ) - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity) - .frame(height: 36) + Label(String(localized: "Show", comment: "Control button"), systemImage: "play.fill") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(height: 36) } .buttonStyle(.borderedProminent) - .tint(isLive ? .orange : .accentColor) + .tint(.accentColor) .keyboardShortcut(.return, modifiers: []) - .disabled(!isLive && currentVerse == nil) + .disabled(currentVerse == nil && libraryManager.songSlideText.isEmpty) + + // Hide (explicit) — blanks the output + Button { + pm.clearOutput() + } label: { + Label(String(localized: "Hide", comment: "Control button"), systemImage: "eye.slash.fill") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.bordered) + .tint(.orange) + .disabled(!isLive) // → Next verse section Button { @@ -180,7 +185,8 @@ struct SongVerseControlsBar: View { title: song.title, verseLabel: verse.label, slideIndex: sortedVerses.firstIndex(where: { $0.id == verse.id }) ?? 0, - slideCount: sortedVerses.count + slideCount: sortedVerses.count, + song: song, version: libraryManager.selectedSongVersion ) } } label: { @@ -206,6 +212,24 @@ struct SongVerseControlsBar: View { } } + private func showCurrent() { + guard let song = currentSong else { return } + if !libraryManager.songSlideText.isEmpty { + pm.showSongVerse( + text: libraryManager.songSlideText, title: song.title, + verseLabel: libraryManager.songSlideLabel, + slideIndex: libraryManager.songSlideIndex, slideCount: libraryManager.songSlideCount, + song: song, version: libraryManager.selectedSongVersion + ) + } else if let verse = currentVerse { + pm.showSongVerse( + text: verse.text, title: song.title, verseLabel: verse.label, + slideIndex: currentIndex ?? 0, slideCount: sortedVerses.count, + song: song, version: libraryManager.selectedSongVersion + ) + } + } + private func canNavigate(direction: Int) -> Bool { guard let idx = currentIndex else { return false } let newIdx = idx + direction @@ -222,7 +246,8 @@ struct SongVerseControlsBar: View { if wasLive, let song = currentSong { pm.showSongVerse( text: verse.text, title: song.title, verseLabel: verse.label, - slideIndex: newIdx, slideCount: sortedVerses.count + slideIndex: newIdx, slideCount: sortedVerses.count, + song: song, version: libraryManager.selectedSongVersion ) } } diff --git a/TopPresenter/Views/Main/PreviewPanelView.swift b/TopPresenter/Views/Main/PreviewPanelView.swift index 7229027..0b9feb8 100644 --- a/TopPresenter/Views/Main/PreviewPanelView.swift +++ b/TopPresenter/Views/Main/PreviewPanelView.swift @@ -326,7 +326,6 @@ struct PresentationPreviewCard: View { } } - @ViewBuilder private func previewBoxText( _ text: String, style: PresentationManager.ResolvedBoxStyle, @@ -1236,7 +1235,7 @@ struct StyleQuickSettings: View { var sections: Set<SettingsSection> = SettingsSection.allSet enum SettingsSection: Hashable { - case multiVerse, general, output + case multiVerse, general, songOptions, output static let allSet: Set<SettingsSection> = [.multiVerse, .general, .output] } @@ -1246,8 +1245,14 @@ struct StyleQuickSettings: View { @AppStorage("settingsExpanded_multiVerse") private var multiVerseExpanded: Bool = false @AppStorage("settingsExpanded_general") private var generalExpanded: Bool = false + @AppStorage("settingsExpanded_songOptions") private var songOptionsExpanded: Bool = true @AppStorage("settingsExpanded_output") private var outputExpanded: Bool = false + // Song options + @AppStorage("song_maxLinesPerSlide") private var songMaxLines: Int = 6 + @AppStorage("song_bilingual") private var songBilingual: Bool = false + @AppStorage("song_repeatStyle") private var songRepeatStyle: String = "none" + // General settings @AppStorage("showVerseNumbers") private var showVerseNumbers: Bool = true @AppStorage("showCrossReferences") private var showCrossReferences: Bool = false @@ -1280,6 +1285,17 @@ struct StyleQuickSettings: View { } } + if sections.contains(.songOptions) { + // ─── Cântece (song presentation options) ─── + settingsSection( + title: String(localized: "Cântece", comment: "Settings section"), + icon: "music.note", + isExpanded: $songOptionsExpanded + ) { + songOptionsSection + } + } + if sections.contains(.output) { // ─── Ieșire (output hardware) ─── settingsSection( @@ -1370,6 +1386,44 @@ struct StyleQuickSettings: View { } } + // MARK: - Cântece Section (song presentation options) + + @ViewBuilder + private var songOptionsSection: some View { + HStack { + Text(String(localized: "Linii/slide:", comment: "Setting label")) + .font(.caption) + .frame(width: 70, alignment: .trailing) + Stepper(value: $songMaxLines, in: 0...20) { + Text(songMaxLines == 0 + ? String(localized: "Nelimitat", comment: "Setting value") + : "\(songMaxLines)") + .font(.caption) + } + .controlSize(.small) + } + + Toggle(String(localized: "Linie de traducere (bilingv)", comment: "Setting label"), isOn: $songBilingual) + .font(.caption) + .controlSize(.small) + + HStack { + Text(String(localized: "Repetare:", comment: "Setting label")) + .font(.caption) + .frame(width: 70, alignment: .trailing) + Picker("", selection: $songRepeatStyle) { + Text(String(localized: "Fără", comment: "Repeat style")).tag("none") + Text("/: :/").tag("slash") + Text("‖: :‖").tag("bar") + Text("|: :|").tag("pipe") + Text("(×N)").tag("times") + Text("bis/ter").tag("bister") + } + .labelsHidden() + .controlSize(.small) + } + } + // MARK: - Ieșire Section (output screen, compact) @ViewBuilder diff --git a/TopPresenter/Views/Presentation/TextBoxLayout.swift b/TopPresenter/Views/Presentation/TextBoxLayout.swift index da80cde..6d1f85b 100644 --- a/TopPresenter/Views/Presentation/TextBoxLayout.swift +++ b/TopPresenter/Views/Presentation/TextBoxLayout.swift @@ -1633,7 +1633,6 @@ struct LayoutEditorSheet: View { } } - @ViewBuilder private func sampleBoxText( _ text: String, style: PresentationManager.ResolvedBoxStyle, diff --git a/TopPresenter/Views/Songs/SongsView.swift b/TopPresenter/Views/Songs/SongsView.swift index e1a4e2d..e894959 100644 --- a/TopPresenter/Views/Songs/SongsView.swift +++ b/TopPresenter/Views/Songs/SongsView.swift @@ -9,7 +9,8 @@ import SwiftUI import SwiftData import UniformTypeIdentifiers -/// Main Songs view with collection management, song navigation, search, and verse display. +/// Main Songs view: scalable library browser (left) + version-aware detail with a rendered +/// slide filmstrip (right). struct SongsView: View { @Environment(\.modelContext) private var modelContext @Environment(LibraryManager.self) private var libraryManager @@ -28,19 +29,23 @@ struct SongsView: View { emptyStateView } else { HSplitView { - // Left: Song list SongListPanel() - .frame(minWidth: 250, maxWidth: 350) + .frame(minWidth: 260, maxWidth: 380) - // Right: Song verses SongDetailPanel() - .frame(minWidth: 400) + .frame(minWidth: 420) } } } .sheet(isPresented: $showImportSheet) { SongImportSheet() } + .sheet(item: Binding( + get: { libraryManager.songToEdit }, + set: { libraryManager.songToEdit = $0 } + )) { song in + SongEditorSheet(song: song) + } .onChange(of: appState.triggerSongImport) { _, newValue in if newValue { showImportSheet = true @@ -59,10 +64,12 @@ struct SongsView: View { } } message: { Text(String(localized: "Are you sure you want to delete \"\(collectionToDelete?.name ?? "")\"? This cannot be undone.", comment: "Alert message")) - } .onKeyWindowNotification(.deleteSongCollection) { _ in + } + .onKeyWindowNotification(.deleteSongCollection) { _ in collectionToDelete = libraryManager.selectedSongCollection showDeleteConfirmation = true - } } + } + } private var emptyStateView: some View { VStack(spacing: 16) { @@ -73,7 +80,7 @@ struct SongsView: View { Text(String(localized: "No Song Collections", comment: "Empty state title")) .font(.title2) - Text(String(localized: "Import songs to get started.\nSupported formats: OpenSong XML, OpenLyrics XML", comment: "Empty state message")) + Text(String(localized: "Import songs to get started.\nSupported: TopPresenter JSON, OpenSong, OpenLyrics, ChordPro, PowerPoint, plain text.", comment: "Empty state message")) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -103,250 +110,569 @@ struct SongsView: View { } } -// MARK: - Song List Panel +// MARK: - Sort + +enum SongSortKey: String, CaseIterable, Identifiable { + case title, author, recent + var id: String { rawValue } + var label: String { + switch self { + case .title: return String(localized: "Titlu", comment: "Sort") + case .author: return String(localized: "Autor", comment: "Sort") + case .recent: return String(localized: "Recente", comment: "Sort") + } + } +} + +// MARK: - Song Library Browser (search, list/grid, filters — scales to thousands) + struct SongListPanel: View { @Environment(LibraryManager.self) private var libraryManager + @Environment(PresentationManager.self) private var presentationManager + @Environment(\.modelContext) private var modelContext @Query(sort: \SongCollection.name) private var collections: [SongCollection] + @AppStorage("song_maxLinesPerSlide") private var maxLines: Int = 6 + @AppStorage("song_repeatStyle") private var repeatStyle = "none" - private var displayedSongs: [Song] { - if !libraryManager.songSearchResults.isEmpty { - // Filter songs from search results - let songIDs = Set(libraryManager.songSearchResults.map { $0.songID }) - return collections.flatMap { $0.songs }.filter { songIDs.contains($0.id) } - } + @State private var query = "" + @State private var isGrid = false + @State private var sortKey: SongSortKey = .title + @State private var languageFilter = "" // "" = all + @State private var collectionFilter: UUID? // nil = all + @State private var onlyWithMedia = false - if let collection = libraryManager.selectedSongCollection { - return collection.sortedSongs + private var sourceSongs: [Song] { + if let id = collectionFilter, let col = collections.first(where: { $0.id == id }) { + return col.songs } + return collections.flatMap { $0.songs } + } - return collections.flatMap { $0.sortedSongs } + private var availableLanguages: [String] { + Array(Set(sourceSongs.map(\.language).filter { !$0.isEmpty })).sorted() + } + + private var filtered: [Song] { + let tokens = query.lowercased().split(separator: " ").map(String.init) + var songs = sourceSongs.filter { song in + if !languageFilter.isEmpty && song.language != languageFilter { return false } + if onlyWithMedia && song.media.isEmpty { return false } + guard !tokens.isEmpty else { return true } + let hay = song.searchText.isEmpty ? song.title.lowercased() : song.searchText + return tokens.allSatisfy { hay.contains($0) } + } + switch sortKey { + case .title: + songs.sort { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + case .author: + songs.sort { $0.author.localizedStandardCompare($1.author) == .orderedAscending } + case .recent: + songs.sort { ($0.collection?.importDate ?? .distantPast) > ($1.collection?.importDate ?? .distantPast) } + } + return songs } var body: some View { - List(displayedSongs, selection: Binding( - get: { libraryManager.selectedSong?.id }, - set: { newID in - if let id = newID, - let song = displayedSongs.first(where: { $0.id == id }) { - libraryManager.selectSong(song) + VStack(spacing: 0) { + toolbar + Divider() + if filtered.isEmpty { + ContentUnavailableView( + String(localized: "Niciun cântec", comment: "Empty"), + systemImage: "magnifyingglass", + description: Text(String(localized: "Ajustează căutarea sau filtrele.", comment: "Empty")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if isGrid { + gridView + } else { + listView + } + } + } + + private var toolbar: some View { + VStack(spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField(String(localized: "Caută cântece…", comment: "Search"), text: $query) + .textFieldStyle(.plain) + if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary) } } - )) { song in - VStack(alignment: .leading, spacing: 2) { - Text(song.title) - .font(.body) - .lineLimit(1) + .padding(6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - HStack { - if !song.author.isEmpty { - Text(song.author) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 6) { + Picker("", selection: $collectionFilter) { + Text(String(localized: "Toate", comment: "All collections")).tag(UUID?.none) + ForEach(collections) { col in + Text(col.name).tag(UUID?.some(col.id)) } - if !song.songNumber.isEmpty { - Text("#\(song.songNumber)") - .font(.caption) - .foregroundStyle(.tertiary) + } + .labelsHidden() + .frame(maxWidth: 140) + + Spacer() + + Menu { + Picker(String(localized: "Sortare", comment: "Sort"), selection: $sortKey) { + ForEach(SongSortKey.allCases) { Text($0.label).tag($0) } + } + if !availableLanguages.isEmpty { + Picker(String(localized: "Limbă", comment: "Language"), selection: $languageFilter) { + Text(String(localized: "Toate", comment: "All")).tag("") + ForEach(availableLanguages, id: \.self) { Text($0.uppercased()).tag($0) } + } } + Toggle(String(localized: "Doar cu media", comment: "Filter"), isOn: $onlyWithMedia) + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .menuStyle(.borderlessButton).fixedSize() + + Button { isGrid.toggle() } label: { + Image(systemName: isGrid ? "list.bullet" : "square.grid.2x2") } + .buttonStyle(.borderless) + .help(isGrid ? String(localized: "Listă", comment: "View") : String(localized: "Grilă", comment: "View")) + } + + HStack { + Text(String(localized: "\(filtered.count) cântece", comment: "Count")) + .font(.caption2).foregroundStyle(.secondary) + Spacer() } - .tag(song.id) - .padding(.vertical, 2) } - .listStyle(.plain) + .padding(8) + } + + private var listView: some View { + List(filtered, selection: Binding( + get: { libraryManager.selectedSong?.id }, + set: { id in if let id, let s = filtered.first(where: { $0.id == id }) { libraryManager.selectSong(s) } } + )) { song in + songRow(song).tag(song.id) + } + .listStyle(.inset) + } + + private func songRow(_ song: Song) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(song.title).font(.body).lineLimit(1) + HStack(spacing: 6) { + if !song.author.isEmpty { + Text(song.author).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + songBadges(song) + } + } + .padding(.vertical, 2) + .contextMenu { songMenu(song) } + } + + private var gridView: some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 164), spacing: 10)], spacing: 10) { + ForEach(filtered) { song in + Button { libraryManager.selectSong(song) } label: { + VStack(spacing: 0) { + SongThemeSlideView(text: firstSlideText(song), fontSize: 8) + .frame(height: 62) + .clipped() + VStack(alignment: .leading, spacing: 3) { + Text(song.title).font(.callout.weight(.medium)).lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + if !song.author.isEmpty { + Text(song.author).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + HStack { songBadges(song); Spacer() } + } + .padding(8) + } + .frame(height: 132, alignment: .top) + .background( + libraryManager.selectedSong?.id == song.id + ? Color.accentColor.opacity(0.18) + : Color.secondary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(libraryManager.selectedSong?.id == song.id ? Color.accentColor : Color.clear, lineWidth: 1.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .contextMenu { songMenu(song) } + } + } + .padding(8) + } + } + + private func firstSlideText(_ song: Song) -> String { + if let section = song.activeVersion?.sortedSections.first, !section.plainText.isEmpty { + return section.plainText + } + return song.sortedVerses.first?.text ?? song.title + } + + @ViewBuilder + private func songMenu(_ song: Song) -> some View { + Button { projectFirstSlide(song) } label: { + Label(String(localized: "Proiectează", comment: "Menu"), systemImage: "play.fill") + } + Button { libraryManager.selectSong(song) } label: { + Label(String(localized: "Deschide", comment: "Menu"), systemImage: "eye") + } + Button { + libraryManager.selectSong(song) + libraryManager.songEditVersionID = nil + libraryManager.songEditSectionKey = nil + libraryManager.songToEdit = song + } label: { + Label(String(localized: "Editează…", comment: "Menu"), systemImage: "pencil") + } + Divider() + Button { exportSong(song) } label: { + Label(String(localized: "Exportă JSON…", comment: "Menu"), systemImage: "square.and.arrow.up") + } + Divider() + Button(role: .destructive) { deleteSong(song) } label: { + Label(String(localized: "Șterge cântecul", comment: "Menu"), systemImage: "trash") + } + } + + private func projectFirstSlide(_ song: Song) { + libraryManager.selectSong(song) + let version = song.activeVersion + let slides = buildSongSlides(song: song, version: version, maxLines: maxLines, bilingual: false, language: nil, repeatStyle: repeatStyle) + guard let first = slides.first else { return } + libraryManager.selectSongSlide(text: first.text, label: first.label, index: 0, count: first.total) + presentationManager.showSongVerse( + text: first.text, title: song.title, verseLabel: first.label, + slideIndex: 0, slideCount: first.total, song: song, version: version + ) + } + + private func exportSong(_ song: Song) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "\(song.title).json" + guard panel.runModal() == .OK, let url = panel.url else { return } + try? ExportService.exportSongToTopPresenterJSON(song).write(to: url, atomically: true, encoding: .utf8) + } + + private func deleteSong(_ song: Song) { + if libraryManager.selectedSong?.id == song.id { + libraryManager.selectedSong = nil + libraryManager.selectedSongVersion = nil + libraryManager.selectedSongVerse = nil + } + modelContext.delete(song) + try? modelContext.save() + } + + @ViewBuilder + private func songBadges(_ song: Song) -> some View { + HStack(spacing: 4) { + if song.versions.count > 1 { badge("\(song.versions.count)×", color: .purple) } + if !song.language.isEmpty { badge(song.language.uppercased(), color: .blue) } + if !song.songNumber.isEmpty { badge("#\(song.songNumber)", color: .gray) } + } + } + + private func badge(_ text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 4).padding(.vertical, 1) + .background(color.opacity(0.2), in: Capsule()) + .foregroundStyle(color) } } -// MARK: - Song Detail Panel +// MARK: - Song Detail (version picker + rendered slide filmstrip) + struct SongDetailPanel: View { @Environment(LibraryManager.self) private var libraryManager - @Environment(PresentationManager.self) private var presentationManager + @AppStorage("song_maxLinesPerSlide") private var maxLines: Int = 6 + @AppStorage("song_bilingual") private var bilingual: Bool = false var body: some View { if let song = libraryManager.selectedSong { + let version = libraryManager.selectedSongVersion ?? song.activeVersion VStack(spacing: 0) { - // Song header - HStack { - VStack(alignment: .leading) { - Text(song.title) - .font(.title3.bold()) - if !song.author.isEmpty { - Text(song.author) - .font(.subheadline) - .foregroundStyle(.secondary) - } + header(song: song, version: version) + Divider() + SongSlideFilmstrip(song: song, version: version, maxLines: maxLines, bilingual: bilingual) + } + } else { + placeholder(icon: "music.note", text: String(localized: "Selectează un cântec", comment: "Placeholder")) + } + } + + @ViewBuilder + private func header(song: Song, version: SongVersion?) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(song.title).font(.title3.bold()).lineLimit(2) + if !song.author.isEmpty { + Text(song.author).font(.subheadline).foregroundStyle(.secondary) } - Spacer() - - if !song.key.isEmpty { - Text(String(localized: "Key: \(song.key)", comment: "Song info")) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.quaternary, in: Capsule()) + } + Spacer() + if let key = version?.key, !key.isEmpty { + infoChip(String(localized: "Ton: \(key)", comment: "Key")) + } + } + HStack(spacing: 8) { + if song.sortedVersions.count > 1 { + Picker("", selection: Binding( + get: { (libraryManager.selectedSongVersion ?? song.activeVersion)?.id }, + set: { id in + if let id, let v = song.versions.first(where: { $0.id == id }) { + libraryManager.selectSongVersion(v) + } + } + )) { + ForEach(song.sortedVersions) { v in + Text(v.name.isEmpty ? String(localized: "Versiune", comment: "Version") : v.name) + .tag(Optional(v.id)) + } } + .labelsHidden().fixedSize() } - .padding(.horizontal) - .padding(.vertical, 8) + if !song.style.isEmpty { infoChip(song.style) } + if let sb = song.songbook { + infoChip(sb.name + (song.songbookNumber.isEmpty ? "" : " #\(song.songbookNumber)")) + } + Spacer() + } + } + .padding(.horizontal).padding(.vertical, 8) + } - Divider() + private func infoChip(_ text: String) -> some View { + Text(text).font(.caption) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(.quaternary, in: Capsule()) + .lineLimit(1) + } + + @ViewBuilder + private func placeholder(icon: String = "music.note", text: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon).font(.system(size: 44)).foregroundStyle(.secondary) + Text(text).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} - // Verse navigation tabs - SongVerseTabBar(song: song) +// MARK: - Song Slides (auto-split + filmstrip) - Divider() +struct SongSlide: Identifiable, Hashable { + let id = UUID() + let sectionKey: String + let label: String + let type: String + let text: String + let index: Int + let total: Int +} - // Current verse display - if let verse = libraryManager.selectedSongVerse { - SongVerseDisplay(verse: verse, songTitle: song.title) - } else { - VStack { - Text(String(localized: "Select a verse section", comment: "Placeholder")) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } +/// Split pre-rendered sections into slides, auto-splitting any section that overflows `maxLines`. +private func splitToSlides(_ sections: [(key: String, label: String, type: String, lines: [String])], maxLines: Int) -> [SongSlide] { + var raw: [(key: String, label: String, type: String, text: String)] = [] + for s in sections { + if maxLines > 0 && s.lines.count > maxLines { + var start = 0 + while start < s.lines.count { + let chunk = Array(s.lines[start..<min(start + maxLines, s.lines.count)]) + raw.append((s.key, s.label, s.type, chunk.joined(separator: "\n"))) + start += maxLines } } else { - VStack { - Image(systemName: "music.note") - .font(.system(size: 48)) - .foregroundStyle(.secondary) - Text(String(localized: "Select a song to view lyrics", comment: "Placeholder")) - .foregroundStyle(.secondary) + raw.append((s.key, s.label, s.type, s.lines.joined(separator: "\n"))) + } + } + let total = max(raw.count, 1) + return raw.enumerated().map { idx, r in + SongSlide(sectionKey: r.key, label: r.label, type: r.type, text: r.text, index: idx, total: total) + } +} + +/// Expand a version into projectable slides: arrangement order → sections → auto-split by `maxLines`. +/// When `bilingual`, each line is paired with its `language` translation (if present). +func buildSongSlides(version: SongVersion, maxLines: Int, bilingual: Bool, language: String?, repeatStyle: String = "none") -> [SongSlide] { + // The version's own style wins over the global default. + let style = version.repeatStyle.isEmpty ? repeatStyle : version.repeatStyle + let sections = version.arrangedSections.map { section -> (key: String, label: String, type: String, lines: [String]) in + var rendered = section.lines.map { line -> String in + if bilingual, let language, let t = line.translations[language], !t.isEmpty { + return line.text + "\n" + t } - .frame(maxWidth: .infinity, maxHeight: .infinity) + return line.text } + rendered = applyRepeatMarker(rendered, count: section.repeatCount, style: style) + return (section.sectionKey, section.label, section.type, rendered) } + return splitToSlides(sections, maxLines: maxLines) } -// MARK: - Song Verse Tab Bar -struct SongVerseTabBar: View { +/// Build slides for a song. Uses the rich version when present; otherwise falls back to the +/// flattened `SongVerse` cache (songs imported before the version model existed). +func buildSongSlides(song: Song, version: SongVersion?, maxLines: Int, bilingual: Bool, language: String?, repeatStyle: String = "none") -> [SongSlide] { + if let version, !version.sections.isEmpty { + return buildSongSlides(version: version, maxLines: maxLines, bilingual: bilingual, language: language, repeatStyle: repeatStyle) + } + let sections = song.sortedVerses.map { v -> (key: String, label: String, type: String, lines: [String]) in + (v.label, v.label, v.verseType, v.text.components(separatedBy: "\n")) + } + return splitToSlides(sections, maxLines: maxLines) +} + +struct SongSlideFilmstrip: View { let song: Song + let version: SongVersion? + let maxLines: Int + let bilingual: Bool @Environment(LibraryManager.self) private var libraryManager + @Environment(PresentationManager.self) private var presentationManager + @AppStorage("song_repeatStyle") private var repeatStyle = "none" - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - ForEach(song.sortedVerses) { verse in - Button { - libraryManager.selectSongVerse(verse) - } label: { - Text(verse.label) - .font(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - libraryManager.selectedSongVerse?.id == verse.id - ? Color.accentColor - : Color.secondary.opacity(0.15), - in: RoundedRectangle(cornerRadius: 6) - ) - .foregroundStyle( - libraryManager.selectedSongVerse?.id == verse.id - ? .white - : .primary - ) - } - .buttonStyle(.plain) - } + private var bilingualLanguage: String? { + guard bilingual else { return nil } + for sec in (version?.sections ?? []) { + for line in sec.lines { + if let lang = line.translations.keys.sorted().first { return lang } } - .padding(.horizontal) - .padding(.vertical, 6) } + return nil } -} -// MARK: - Song Verse Display -struct SongVerseDisplay: View { - let verse: SongVerse - let songTitle: String - - @Environment(PresentationManager.self) private var presentationManager - @Environment(LibraryManager.self) private var libraryManager + private var slides: [SongSlide] { + buildSongSlides(song: song, version: version, maxLines: maxLines, bilingual: bilingual, language: bilingualLanguage, repeatStyle: repeatStyle) + } var body: some View { - VStack(spacing: 0) { + if slides.isEmpty { + ContentUnavailableView( + String(localized: "Niciun slide", comment: "Empty"), + systemImage: "rectangle.on.rectangle.slash", + description: Text(String(localized: "Acest cântec nu are versuri.", comment: "Empty")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { ScrollView { - Text(verse.text) - .font(.title3) - .multilineTextAlignment(.center) - .textSelection(.enabled) - .frame(maxWidth: .infinity) - .padding(24) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) { + ForEach(slides) { slide in + SongSlideThumbnail( + slide: slide, + isSelected: libraryManager.songSlideLabel == slide.label + && libraryManager.songSlideText == slide.text, + onShow: { project(slide) }, + onEdit: { + libraryManager.songEditVersionID = version?.id + libraryManager.songEditSectionKey = slide.sectionKey + libraryManager.songToEdit = song + } + ) + // Double-tap projects; single-tap selects (updates the sidebar's rendered preview). + .onTapGesture(count: 2) { project(slide) } + .onTapGesture { select(slide) } + .contextMenu { + Button { + project(slide) + } label: { + Label(String(localized: "Proiectează", comment: "Menu"), systemImage: "play.fill") + } + Button { + select(slide) + } label: { + Label(String(localized: "Previzualizează", comment: "Menu"), systemImage: "eye") + } + Divider() + Button { + libraryManager.songEditVersionID = version?.id + libraryManager.songEditSectionKey = slide.sectionKey + libraryManager.songToEdit = song + } label: { + Label(String(localized: "Editează cântecul…", comment: "Menu"), systemImage: "pencil") + } + } + } + } + .padding(12) } + } + } - Divider() + private func select(_ slide: SongSlide) { + libraryManager.selectSongSlide(text: slide.text, label: slide.label, index: slide.index, count: slide.total) + } - // Action bar - HStack { - Text(verse.label) - .font(.caption) - .foregroundStyle(.secondary) + private func project(_ slide: SongSlide) { + select(slide) + presentationManager.showSongVerse( + text: slide.text, title: song.title, verseLabel: slide.label, + slideIndex: slide.index, slideCount: slide.total, + song: song, version: version + ) + } +} - Spacer() +struct SongSlideThumbnail: View { + let slide: SongSlide + let isSelected: Bool + var onShow: () -> Void = {} + var onEdit: () -> Void = {} - // Previous verse - Button { - navigateVerse(direction: -1) - } label: { - Image(systemName: "chevron.left") - } - .keyboardShortcut(.leftArrow, modifiers: []) - .disabled(!canNavigate(direction: -1)) - - // Show on screen - Button { - let sorted = libraryManager.selectedSong?.sortedVerses ?? [] - presentationManager.showSongVerse( - text: verse.text, - title: songTitle, - verseLabel: verse.label, - slideIndex: sorted.firstIndex(where: { $0.id == verse.id }) ?? 0, - slideCount: max(sorted.count, 1) - ) - } label: { - Label( - String(localized: "Show", comment: "Button"), - systemImage: "play.fill" - ) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .keyboardShortcut(.return, modifiers: []) + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .topTrailing) { + SongThemeSlideView(text: slide.text.isEmpty ? slide.label : slide.text, fontSize: 9) + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 6)) - // Next verse - Button { - navigateVerse(direction: 1) - } label: { - Image(systemName: "chevron.right") + // Quick actions: show on screen + edit + HStack(spacing: 6) { + Button(action: onShow) { Image(systemName: "play.fill") } + .help(String(localized: "Arată pe ecran", comment: "Tooltip")) + Button(action: onEdit) { Image(systemName: "pencil") } + .help(String(localized: "Editează", comment: "Tooltip")) } - .keyboardShortcut(.rightArrow, modifiers: []) - .disabled(!canNavigate(direction: 1)) + .buttonStyle(.plain) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .padding(5) + .background(.black.opacity(0.4), in: Capsule()) + .padding(5) } - .padding(.horizontal) - .padding(.vertical, 8) - } - } - private func canNavigate(direction: Int) -> Bool { - guard let song = libraryManager.selectedSong else { return false } - let sorted = song.sortedVerses - guard let currentIndex = sorted.firstIndex(where: { $0.id == verse.id }) else { return false } - let newIndex = currentIndex + direction - return newIndex >= 0 && newIndex < sorted.count - } - - private func navigateVerse(direction: Int) { - guard let song = libraryManager.selectedSong else { return } - let sorted = song.sortedVerses - guard let currentIndex = sorted.firstIndex(where: { $0.id == verse.id }) else { return } - let newIndex = currentIndex + direction - if newIndex >= 0 && newIndex < sorted.count { - libraryManager.selectSongVerse(sorted[newIndex]) + HStack { + Text(slide.label).font(.caption2.weight(.medium)).lineLimit(1) + Spacer() + Text("\(slide.index + 1)/\(slide.total)") + .font(.system(size: 9)).foregroundStyle(.secondary) + } + .padding(.horizontal, 4).padding(.vertical, 3) } + .background( + isSelected ? Color.accentColor.opacity(0.18) : Color.secondary.opacity(0.06), + in: RoundedRectangle(cornerRadius: 8) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : .clear, lineWidth: 2) + ) + .contentShape(Rectangle()) } } @@ -361,25 +687,44 @@ struct SongImportSheet: View { @State private var isImporting = false @State private var importProgress: Double = 0 @State private var importStatusText = "" + @State private var dupMode = "version" // version | keepBoth | skip + @Query(sort: \SongCollection.name) private var existingCollections: [SongCollection] var body: some View { VStack(spacing: 20) { Text(String(localized: "Import Songs", comment: "Sheet title")) .font(.title2.bold()) - Text(String(localized: "Alege fișiere individuale și/sau directoare — formatul fiecărui fișier este detectat automat (OpenSong, OpenLyrics, PPTX, PPT).", comment: "Import sheet hint")) + Text(String(localized: "Alege fișiere și/sau directoare (subdirectoarele sunt incluse) — formatul fiecărui fișier este detectat automat.", comment: "Import sheet hint")) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - // Collection name - TextField( - String(localized: "Collection Name", comment: "Text field placeholder"), - text: $collectionName - ) - .textFieldStyle(.roundedBorder) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + TextField( + String(localized: "Nume folder (gol = Nesortate)", comment: "Text field placeholder"), + text: $collectionName + ) + .textFieldStyle(.roundedBorder) + if !existingCollections.isEmpty { + Menu { + ForEach(existingCollections) { c in + Button(c.name) { collectionName = c.name } + } + } label: { Image(systemName: "folder") } + .menuStyle(.borderlessButton).fixedSize() + .help(String(localized: "Alege un folder existent", comment: "Tooltip")) + } + } + Picker(String(localized: "La nume existent", comment: "Label"), selection: $dupMode) { + Text(String(localized: "Versiune nouă", comment: "Option")).tag("version") + Text(String(localized: "Cântec nou", comment: "Option")).tag("keepBoth") + Text(String(localized: "Sări peste", comment: "Option")).tag("skip") + } + .pickerStyle(.segmented) + } - // Selection summary + chooser HStack { if selectedURLs.isEmpty { Text(String(localized: "Nimic selectat", comment: "Placeholder")) @@ -429,7 +774,7 @@ struct SongImportSheet: View { Spacer() Button(String(localized: "Import", comment: "Button")) { performImport() } .buttonStyle(.borderedProminent) - .disabled(selectedURLs.isEmpty || collectionName.isEmpty || isImporting) + .disabled(selectedURLs.isEmpty || isImporting) .keyboardShortcut(.defaultAction) } } @@ -439,8 +784,6 @@ struct SongImportSheet: View { private func chooseLocation() { let panel = NSOpenPanel() - // Files AND directories, multi-select, ANY type — auto-detection - // decides how each file is parsed. panel.canChooseDirectories = true panel.canChooseFiles = true panel.allowsMultipleSelection = true @@ -457,12 +800,23 @@ struct SongImportSheet: View { private func performImport() { guard !selectedURLs.isEmpty else { return } isImporting = true + let targetName = collectionName.trimmingCharacters(in: .whitespaces).isEmpty + ? String(localized: "Nesortate", comment: "Default collection") + : collectionName + let resolution: SongDuplicateResolution = { + switch dupMode { + case "keepBoth": return .keepBoth + case "skip": return .skip + default: return .addAsVersion + } + }() Task { let result = await ImportService.importSongItems( urls: selectedURLs, - collectionName: collectionName, - modelContext: modelContext + collectionName: targetName, + modelContext: modelContext, + duplicateResolution: resolution ) { progress, status in Task { @MainActor in importProgress = progress @@ -475,7 +829,7 @@ struct SongImportSheet: View { if result.failures.isEmpty, !result.importedTitles.isEmpty { appState.showSuccess( String(localized: "Import Successful", comment: "Alert title"), - message: String(localized: "Au fost importate \(result.importedTitles.count) cântece în \"\(collectionName)\".", comment: "Alert message") + message: String(localized: "Au fost importate \(result.importedTitles.count) cântece în \"\(targetName)\".", comment: "Alert message") ) dismiss() } else if result.importedTitles.isEmpty { @@ -504,3 +858,841 @@ struct SongImportSheet: View { } } } + +// MARK: - Song Editor (visual editing of all stored content) + +let songSectionTypes = ["verse", "chorus", "bridge", "prechorus", "intro", "ending", "tag", "interlude", "other"] + +func songTypeColor(_ type: String) -> Color { + switch type { + case "chorus": return .orange + case "bridge": return .purple + case "prechorus": return .pink + case "intro", "ending": return .teal + case "tag": return .green + case "interlude": return .indigo + default: return .blue // verse / other + } +} + +func songTypeLabel(_ type: String) -> String { + switch type { + case "verse": return String(localized: "Strofă", comment: "Section type") + case "chorus": return String(localized: "Refren", comment: "Section type") + case "bridge": return String(localized: "Punte", comment: "Section type") + case "prechorus": return String(localized: "Pre-refren", comment: "Section type") + case "intro": return String(localized: "Intro", comment: "Section type") + case "ending": return String(localized: "Final", comment: "Section type") + case "tag": return String(localized: "Tag", comment: "Section type") + case "interlude": return String(localized: "Interludiu", comment: "Section type") + default: return String(localized: "Altul", comment: "Section type") + } +} + +/// A theme-rendered slide: the song profile's background (image > color > global) with the +/// theme text color — so previews/thumbnails look like the real output, not a plain box. +struct SongThemeSlideView: View { + let text: String + var fontSize: CGFloat = 13 + @Environment(PresentationManager.self) private var pm + + var body: some View { + let bg = pm.backgroundConfig(for: "song") + ZStack { + if bg.enabled, bg.useImage, let img = pm.contentBackgroundImages["song"] { + Image(nsImage: img).resizable().scaledToFill() + } else if bg.enabled, bg.showColor { + (Color(hex: bg.colorHex) ?? .black) + } else { + pm.backgroundColor + } + Text(text) + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(Color(hex: pm.textColorHex) ?? .white) + .multilineTextAlignment(.center) + .lineLimit(8) + .padding(8) + .shadow(color: .black.opacity(0.45), radius: 1, y: 0.5) + } + .clipped() + } +} + +// ChordPro round-trip so the editor can show/edit inline [G] chords without losing them. +func songLinesToChordPro(_ lines: [SongLine]) -> String { + lines.map { line in + let chords = line.chords.sorted { $0.pos < $1.pos } + var out = "" + var ci = 0 + for (i, ch) in line.text.enumerated() { + while ci < chords.count && chords[ci].pos <= i { out += "[\(chords[ci].sym)]"; ci += 1 } + out.append(ch) + } + while ci < chords.count { out += "[\(chords[ci].sym)]"; ci += 1 } + return out + }.joined(separator: "\n") +} + +func songChordProToLines(_ text: String) -> [SongLine] { + text.components(separatedBy: "\n").map { raw in + var t = "" + var chords: [SongChord] = [] + var i = raw.startIndex + while i < raw.endIndex { + if raw[i] == "[", let close = raw[i...].firstIndex(of: "]") { + let sym = String(raw[raw.index(after: i)..<close]) + if !sym.isEmpty { chords.append(SongChord(sym: sym, pos: t.count)) } + i = raw.index(after: close) + continue + } + t.append(raw[i]) + i = raw.index(after: i) + } + return SongLine(text: t, chords: chords) + } +} + +/// Apply the theme's repeat-marker style to a repeated section's lines (count > 1). +func applyRepeatMarker(_ lines: [String], count: Int, style: String) -> [String] { + guard count > 1, !lines.isEmpty, style != "none" else { return lines } + var out = lines + switch style { + case "slash": out[0] = "/: " + out[0]; out[out.count - 1] += " :/" + case "bar": out[0] = "‖: " + out[0]; out[out.count - 1] += " :‖" + case "pipe": out[0] = "|: " + out[0]; out[out.count - 1] += " :|" + case "times": out.append("(×\(count))") + case "bister": out.append(count == 2 ? "bis" : (count == 3 ? "ter" : "×\(count)")) + default: break + } + return out +} + +struct SongEditorSheet: View { + @Bindable var song: Song + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Environment(LibraryManager.self) private var libraryManager + @Query(sort: \Songbook.name) private var allSongbooks: [Songbook] + @State private var selectedVersionID: UUID? + @State private var focusedSectionID: UUID? + @State private var newBookName = "" + @AppStorage("song_repeatStyle") private var globalRepeatStyle = "none" + + private var currentVersion: SongVersion? { + if let id = selectedVersionID, let v = song.versions.first(where: { $0.id == id }) { return v } + return song.activeVersion + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + versionTabBar + Divider() + HSplitView { + metadataPane + .frame(minWidth: 300, idealWidth: 340, maxWidth: 400) + versionPane + .frame(minWidth: 440) + } + } + .frame(minWidth: 900, idealWidth: 1020, minHeight: 660, idealHeight: 800) + .onAppear { ensureVersion() } + } + + private var isOriginalVersion: Bool { + currentVersion?.id == song.sortedVersions.first?.id + } + + // MARK: Version tabs (top) + private var versionTabBar: some View { + HStack(spacing: 8) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(song.sortedVersions.enumerated()), id: \.element.id) { idx, v in + let selected = currentVersion?.id == v.id + let title = idx == 0 + ? String(localized: "Original", comment: "Version") + : (v.name.isEmpty ? "Versiunea \(idx + 1)" : v.name) + Button { + selectedVersionID = v.id + focusedSectionID = nil + } label: { + HStack(spacing: 4) { + Image(systemName: idx == 0 ? "star.fill" : "doc.on.doc").font(.system(size: 9)) + Text(title).font(.caption.weight(.medium)).lineLimit(1) + } + .padding(.horizontal, 12).padding(.vertical, 6) + .background(selected ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule()) + .foregroundStyle(selected ? .white : .primary) + } + .buttonStyle(.plain) + .contextMenu { + if idx != 0 { + Button(role: .destructive) { deleteVersion(v) } label: { + Label(String(localized: "Șterge versiunea", comment: "Menu"), systemImage: "trash") + } + } + } + } + } + } + Button { addVersion() } label: { + Label(String(localized: "Versiune", comment: "Button"), systemImage: "plus").font(.caption) + } + .buttonStyle(.bordered) + .help(String(localized: "Adaugă o versiune (copie a originalului)", comment: "Tooltip")) + } + .padding(.horizontal, 16).padding(.vertical, 8) + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "music.note.list") + .font(.title2) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 1) { + Text(String(localized: "Editor cântec", comment: "Editor title")).font(.headline) + Text(song.title.isEmpty ? String(localized: "Fără titlu", comment: "Placeholder") : song.title) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button(String(localized: "Gata", comment: "Button")) { save(); dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 16).padding(.vertical, 12) + } + + // MARK: Metadata pane (left) — per-version metadata + shared song identity + private var metadataPane: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + versionMetadataGroup + songIdentityGroup + } + .padding(16) + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + @ViewBuilder private var versionMetadataGroup: some View { + if let v = currentVersion { + let editable = isOriginalVersion || v.overridesMetadata + let src = editable ? v : (song.sortedVersions.first ?? v) // inherited values shown read-only + VStack(alignment: .leading, spacing: 8) { + if !isOriginalVersion { + Toggle(isOn: Binding( + get: { v.overridesMetadata }, + set: { on in + if on && !v.overridesMetadata { seedOverrides(into: v) } // pre-fill from original + v.overridesMetadata = on + } + )) { + Text(String(localized: "Date proprii pentru versiune", comment: "Toggle")).font(.caption.bold()) + } + .toggleStyle(.switch).controlSize(.small) + if !v.overridesMetadata { + Text(String(localized: "Moștenește datele din „Original”. Activează pentru a edita.", comment: "Hint")) + .font(.system(size: 10)).foregroundStyle(.tertiary) + } + } + editorGroup(String(localized: "Date versiune", comment: "Group"), icon: "rectangle.stack") { + versionField(String(localized: "Titlu afișat", comment: "Field"), src: src, keyPath: \.displayTitle, editable: editable) + versionField(String(localized: "Autor", comment: "Field"), src: src, keyPath: \.author, editable: editable) + HStack { + versionField(String(localized: "Versuri de", comment: "Field"), src: src, keyPath: \.authorWords, editable: editable) + versionField(String(localized: "Muzica de", comment: "Field"), src: src, keyPath: \.authorMusic, editable: editable) + } + versionField(String(localized: "Traducere de", comment: "Field"), src: src, keyPath: \.authorTranslation, editable: editable) + HStack { + versionField(String(localized: "Limbă", comment: "Field"), src: src, keyPath: \.language, editable: editable, width: 64) + versionField(String(localized: "Ton", comment: "Field"), src: src, keyPath: \.key, editable: editable, width: 64) + versionField(String(localized: "Tempo", comment: "Field"), src: src, keyPath: \.tempo, editable: editable, width: 64) + } + versionSongbookField(src: src, editable: editable) + HStack { + versionField(String(localized: "Stil", comment: "Field"), src: src, keyPath: \.style, editable: editable) + versionField(String(localized: "Nr. carte", comment: "Field"), src: src, keyPath: \.songbookNumber, editable: editable, width: 80) + } + HStack { + versionField(String(localized: "Copyright", comment: "Field"), src: src, keyPath: \.copyright, editable: editable) + versionField(String(localized: "CCLI", comment: "Field"), src: src, keyPath: \.ccliNumber, editable: editable, width: 90) + } + versionListField(String(localized: "Teme", comment: "Field"), get: { src.themes }, set: { src.themes = $0 }, editable: editable) + if editable { + HStack { + Stepper("Capo \(src.capo)", value: Binding(get: { src.capo }, set: { src.capo = $0 }), in: 0...11) + .font(.caption).fixedSize() + Spacer() + versionRepeatPicker(src) + } + } else { + HStack { + Text("Capo \(src.capo)").font(.callout) + Spacer() + Text(String(localized: "Repetare: \(repeatStyleLabel(src.repeatStyle))", comment: "Info")) + .font(.callout).foregroundStyle(.secondary) + } + } + versionNotesField(src: src, editable: editable) + } + } + } + } + + @ViewBuilder private var songIdentityGroup: some View { + editorGroup(String(localized: "Identitate cântec", comment: "Group"), icon: "music.note") { + field(String(localized: "Titlu canonic (pentru bibliotecă)", comment: "Field"), text: $song.title) + field(String(localized: "Alte titluri (toate versiunile)", comment: "Field"), + text: listBinding(get: { song.titles }, set: { song.titles = $0 }), + hint: String(localized: "separate prin virgulă", comment: "Hint")) + Text(String(localized: "Restul câmpurilor se editează per versiune, mai sus.", comment: "Hint")) + .font(.system(size: 10)).foregroundStyle(.tertiary) + } + } + + @ViewBuilder + private func versionSongbookField(src: SongVersion, editable: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Carte de cântări", comment: "Field")).font(.caption2).foregroundStyle(.secondary) + if editable { + HStack(spacing: 6) { + Picker("", selection: Binding(get: { src.songbookName }, set: { src.songbookName = $0 })) { + Text(String(localized: "Fără carte", comment: "Option")).tag("") + ForEach(allSongbooks) { book in Text(book.name).tag(book.name) } + if !src.songbookName.isEmpty && !allSongbooks.contains(where: { $0.name == src.songbookName }) { + Text(src.songbookName).tag(src.songbookName) + } + } + .labelsHidden() + TextField(String(localized: "Carte nouă…", comment: "Field"), text: $newBookName) + .textFieldStyle(.roundedBorder) + Button(String(localized: "Adaugă", comment: "Button")) { addBook(to: src) } + .disabled(newBookName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } else { + Text(src.songbookName.isEmpty ? "—" : src.songbookName) + .font(.callout).foregroundStyle(src.songbookName.isEmpty ? .tertiary : .primary) + } + } + } + + private func addBook(to src: SongVersion) { + let name = newBookName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + if !allSongbooks.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + modelContext.insert(Songbook(name: name)) // add to the shared list of books + } + src.songbookName = name + newBookName = "" + } + + @ViewBuilder + private func versionListField(_ label: String, get: @escaping () -> [String], set: @escaping ([String]) -> Void, editable: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + if editable { + TextField(String(localized: "separate prin virgulă", comment: "Hint"), + text: Binding(get: { get().joined(separator: ", ") }, + set: { set($0.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) })) + .textFieldStyle(.roundedBorder) + } else { + let joined = get().joined(separator: ", ") + Text(joined.isEmpty ? "—" : joined) + .font(.callout).foregroundStyle(joined.isEmpty ? .tertiary : .primary) + } + } + } + + @ViewBuilder + private func versionNotesField(src: SongVersion, editable: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Note", comment: "Field")).font(.caption2).foregroundStyle(.secondary) + if editable { + TextEditor(text: Binding(get: { src.notes }, set: { src.notes = $0 })) + .font(.body).frame(minHeight: 44) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + } else { + Text(src.notes.isEmpty ? "—" : src.notes) + .font(.callout).foregroundStyle(src.notes.isEmpty ? .tertiary : .primary) + } + } + } + + /// Seed a version's override fields from the original (so toggling "own data" starts from + /// the inherited values rather than blanks). + private func seedOverrides(into v: SongVersion) { + guard let orig = song.sortedVersions.first, orig.id != v.id else { return } + if v.displayTitle.isEmpty { v.displayTitle = orig.displayTitle.isEmpty ? song.title : orig.displayTitle } + if v.author.isEmpty { v.author = orig.author.isEmpty ? song.author : orig.author } + if v.language.isEmpty { v.language = orig.language } + if v.key.isEmpty { v.key = orig.key } + if v.tempo.isEmpty { v.tempo = orig.tempo } + if v.copyright.isEmpty { v.copyright = orig.copyright } + if v.ccliNumber.isEmpty { v.ccliNumber = orig.ccliNumber } + if v.capo == 0 { v.capo = orig.capo } + if v.repeatStyle.isEmpty { v.repeatStyle = orig.repeatStyle } + if v.authorWords.isEmpty { v.authorWords = orig.authorWords } + if v.authorMusic.isEmpty { v.authorMusic = orig.authorMusic } + if v.authorTranslation.isEmpty { v.authorTranslation = orig.authorTranslation } + if v.style.isEmpty { v.style = orig.style } + if v.songbookNumber.isEmpty { v.songbookNumber = orig.songbookNumber } + if v.songbookName.isEmpty { v.songbookName = orig.songbookName } + if v.themes.isEmpty { v.themes = orig.themes } + if v.notes.isEmpty { v.notes = orig.notes } + } + + /// Existing/imported songs store metadata on the Song. Copy it onto the original version + /// (where empty) so the per-version editor and inheritance have a real source. + private func hydrateOriginalFromSong() { + guard let orig = song.sortedVersions.first else { return } + if orig.displayTitle.isEmpty { orig.displayTitle = song.title } + if orig.author.isEmpty { orig.author = song.author } + if orig.authorWords.isEmpty { orig.authorWords = song.authorWords } + if orig.authorMusic.isEmpty { orig.authorMusic = song.authorMusic } + if orig.authorTranslation.isEmpty { orig.authorTranslation = song.authorTranslation } + if orig.language.isEmpty { orig.language = song.language } + if orig.key.isEmpty { orig.key = song.key } + if orig.tempo.isEmpty { orig.tempo = song.tempo } + if orig.copyright.isEmpty { orig.copyright = song.copyright } + if orig.ccliNumber.isEmpty { orig.ccliNumber = song.ccliNumber } + if orig.style.isEmpty { orig.style = song.style } + if orig.songbookNumber.isEmpty { orig.songbookNumber = song.songbookNumber } + if orig.songbookName.isEmpty { orig.songbookName = song.songbook?.name ?? "" } + if orig.themes.isEmpty { orig.themes = song.themes } + if orig.notes.isEmpty { orig.notes = song.notes } + } + + /// Mirror the original version's metadata back onto the Song so the library browser + /// and search stay accurate. + private func mirrorOriginalToSong() { + guard let orig = song.sortedVersions.first else { return } + if !orig.displayTitle.isEmpty { song.title = orig.displayTitle } + song.author = orig.author + song.authorWords = orig.authorWords + song.authorMusic = orig.authorMusic + song.authorTranslation = orig.authorTranslation + song.language = orig.language + song.key = orig.key + song.tempo = orig.tempo + song.copyright = orig.copyright + song.ccliNumber = orig.ccliNumber + song.style = orig.style + song.songbookNumber = orig.songbookNumber + if !orig.songbookName.isEmpty { + song.songbook = allSongbooks.first { $0.name.caseInsensitiveCompare(orig.songbookName) == .orderedSame } + ?? { let b = Songbook(name: orig.songbookName); modelContext.insert(b); return b }() + } + song.themes = orig.themes + song.notes = orig.notes + } + + @ViewBuilder + private func versionField(_ label: String, src: SongVersion, keyPath: ReferenceWritableKeyPath<SongVersion, String>, editable: Bool, width: CGFloat? = nil) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + if editable { + TextField("", text: Binding(get: { src[keyPath: keyPath] }, set: { src[keyPath: keyPath] = $0 })) + .textFieldStyle(.roundedBorder).frame(width: width) + } else { + Text(src[keyPath: keyPath].isEmpty ? "—" : src[keyPath: keyPath]) + .font(.callout) + .foregroundStyle(src[keyPath: keyPath].isEmpty ? .tertiary : .primary) + .frame(width: width, alignment: .leading) + } + } + } + + private func repeatStyleLabel(_ style: String) -> String { + switch style { + case "slash": return "/: :/" + case "bar": return "‖: :‖" + case "pipe": return "|: :|" + case "times": return "(×N)" + case "bister": return "bis/ter" + case "none": return String(localized: "Fără", comment: "Repeat style") + default: return String(localized: "Implicit", comment: "Repeat style") + } + } + + // MARK: Version pane (right) + private var versionPane: some View { + VStack(spacing: 0) { + if let version = currentVersion, let section = focusedSection(in: version) { + let style = version.repeatStyle.isEmpty ? globalRepeatStyle : version.repeatStyle + let previewLines = applyRepeatMarker(section.lines.map { $0.text }, count: section.repeatCount, style: style) + let previewText = previewLines.joined(separator: "\n") + SongThemeSlideView(text: previewText.isEmpty ? section.label : previewText, fontSize: 16) + .frame(height: 150) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(12) + Divider() + } + + if let version = currentVersion { + ScrollView { + LazyVStack(spacing: 10) { + ForEach(version.sortedSections) { section in + SectionEditorCard( + section: section, + isFocused: focusedSectionID == section.id, + onFocus: { focusedSectionID = section.id }, + onMoveUp: { move(section, by: -1, in: version) }, + onMoveDown: { move(section, by: 1, in: version) }, + onDelete: { modelContext.delete(section) }, + onDuplicate: { duplicateSection(section, in: version) }, + onDrop: { draggedID in reorder(draggedID: draggedID, before: section, in: version) } + ) + } + addSectionMenu(version) + } + .padding(12) + } + } else { + ContentUnavailableView(String(localized: "Nicio versiune", comment: "Empty"), systemImage: "music.note") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func addSectionMenu(_ version: SongVersion) -> some View { + Menu { + ForEach(songSectionTypes, id: \.self) { type in + Button(songTypeLabel(type)) { addSection(type, to: version) } + } + } label: { + Label(String(localized: "Adaugă strofă", comment: "Button"), systemImage: "plus.rectangle.on.rectangle") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + .menuStyle(.borderlessButton) + } + + // MARK: small builders + @ViewBuilder + private func editorGroup<C: View>(_ title: String, icon: String, @ViewBuilder content: () -> C) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: icon) + .font(.caption.bold()).foregroundStyle(.secondary) + content() + } + .padding(12) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary)) + } + + private func field(_ label: String, text: Binding<String>, hint: String = "") -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + TextField(hint, text: text).textFieldStyle(.roundedBorder) + } + } + + private func compactField(_ label: String, text: Binding<String>, width: CGFloat? = nil) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.system(size: 9)).foregroundStyle(.secondary) + TextField("", text: text).textFieldStyle(.roundedBorder).frame(width: width) + } + } + + private func versionRepeatPicker(_ version: SongVersion) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Repetare", comment: "Field")).font(.system(size: 9)).foregroundStyle(.secondary) + Picker("", selection: Binding(get: { version.repeatStyle }, set: { version.repeatStyle = $0 })) { + Text(String(localized: "Implicit", comment: "Option")).tag("") + Text(String(localized: "Fără", comment: "Option")).tag("none") + Text("/: :/").tag("slash") + Text("‖: :‖").tag("bar") + Text("|: :|").tag("pipe") + Text("(×N)").tag("times") + Text("bis/ter").tag("bister") + } + .labelsHidden().controlSize(.small).fixedSize() + } + } + + private var songbookField: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Carte de cântări", comment: "Field")).font(.caption2).foregroundStyle(.secondary) + Picker("", selection: Binding( + get: { song.songbook?.id }, + set: { id in song.songbook = allSongbooks.first { $0.id == id } } + )) { + Text(String(localized: "Fără carte", comment: "Option")).tag(UUID?.none) + ForEach(allSongbooks) { book in + Text(book.name).tag(UUID?.some(book.id)) + } + } + .labelsHidden() + HStack(spacing: 6) { + TextField(String(localized: "Carte nouă…", comment: "Field"), text: $newBookName) + .textFieldStyle(.roundedBorder) + Button(String(localized: "Adaugă", comment: "Button")) { addBook() } + .disabled(newBookName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + + private func addBook() { + let name = newBookName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + if let existing = allSongbooks.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + song.songbook = existing + } else { + let book = Songbook(name: name) + modelContext.insert(book) + song.songbook = book + } + newBookName = "" + } + + private func focusedSection(in version: SongVersion) -> SongSection? { + if let id = focusedSectionID, let s = version.sections.first(where: { $0.id == id }) { return s } + return version.sortedSections.first + } + + private func addSection(_ type: String, to version: SongVersion) { + let n = version.sections.count + let s = SongSection(sectionKey: "\(type.prefix(1))\(n + 1)", type: type, + label: "\(songTypeLabel(type)) \(n + 1)", order: n, lines: [SongLine(text: "")]) + s.version = version + focusedSectionID = s.id + } + + private func duplicateSection(_ section: SongSection, in version: SongVersion) { + // Make room right after the original, then insert the copy there. + for s in version.sortedSections where s.order > section.order { + s.order += 1 + } + let copy = SongSection( + sectionKey: "\(section.sectionKey)2", type: section.type, label: section.label, + order: section.order + 1, repeatCount: section.repeatCount, lines: section.lines + ) + copy.version = version + focusedSectionID = copy.id + } + + private func move(_ section: SongSection, by delta: Int, in version: SongVersion) { + let sorted = version.sortedSections + guard let i = sorted.firstIndex(where: { $0.id == section.id }) else { return } + let j = i + delta + guard j >= 0, j < sorted.count else { return } + let tmp = sorted[i].order + sorted[i].order = sorted[j].order + sorted[j].order = tmp + } + + /// Drag-reorder: move the dragged section to sit just before `target`, then renumber. + private func reorder(draggedID: String, before target: SongSection, in version: SongVersion) { + guard let dragged = version.sections.first(where: { $0.id.uuidString == draggedID }), + dragged.id != target.id else { return } + var ordered = version.sortedSections + ordered.removeAll { $0.id == dragged.id } + guard let ti = ordered.firstIndex(where: { $0.id == target.id }) else { return } + ordered.insert(dragged, at: ti) + for (i, s) in ordered.enumerated() { s.order = i } + } + + private func listBinding(get: @escaping () -> [String], set: @escaping ([String]) -> Void) -> Binding<String> { + Binding( + get: { get().joined(separator: ", ") }, + set: { set($0.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) } + ) + } + + /// Older songs (imported before versions existed) get a version synthesized from the + /// flattened cache the first time they're edited — an on-demand backfill. + private func ensureVersion() { + if song.versions.isEmpty { + let v = SongVersion(name: "Original", order: 0) + v.song = song + for (i, verse) in song.sortedVerses.enumerated() { + let s = SongSection( + sectionKey: verse.label.isEmpty ? "s\(i)" : verse.label, + type: verse.verseType, label: verse.label, order: i, + lines: verse.text.components(separatedBy: "\n").map { SongLine(text: $0) } + ) + s.version = v + } + modelContext.insert(v) + selectedVersionID = v.id + } else { + // Open the version the slide came from (if any), else the original/active one. + selectedVersionID = libraryManager.songEditVersionID ?? song.activeVersion?.id + } + hydrateOriginalFromSong() + libraryManager.songEditVersionID = nil + // Focus the section the user clicked "edit" on (from a slide), else the first. + if let key = libraryManager.songEditSectionKey, + let match = currentVersion?.sections.first(where: { $0.sectionKey == key }) { + focusedSectionID = match.id + libraryManager.songEditSectionKey = nil + } else { + focusedSectionID = currentVersion?.sortedSections.first?.id + } + } + + /// New versions start as a copy of the current one (key/tempo/arrangement + all sections). + private func addVersion() { + let v = SongVersion(name: "Versiunea \(song.versions.count + 1)", order: song.versions.count) + v.song = song + if let source = song.activeVersion { + v.key = source.key + v.capo = source.capo + v.tempo = source.tempo + v.timeSignature = source.timeSignature + v.language = source.language + v.arrangement = source.arrangement + for sec in source.sortedSections { + let copy = SongSection(sectionKey: sec.sectionKey, type: sec.type, label: sec.label, order: sec.order, lines: sec.lines) + copy.version = v + } + } else { + let s = SongSection(sectionKey: "v1", type: "verse", label: "Strofa 1", order: 0, lines: [SongLine(text: "")]) + s.version = v + } + modelContext.insert(v) + selectedVersionID = v.id + focusedSectionID = v.sortedSections.first?.id + } + + private func deleteVersion(_ v: SongVersion) { + let fallback = song.sortedVersions.first { $0.id != v.id }?.id + modelContext.delete(v) + selectedVersionID = fallback + } + + /// Keep the flattened SongVerse cache + searchText in sync with the active version. + private func save() { + mirrorOriginalToSong() + for old in song.verses { modelContext.delete(old) } + var lyrics = "" + if let active = song.activeVersion { + for (i, sec) in active.sortedSections.enumerated() { + let verse = SongVerse(label: sec.label, verseType: sec.type, text: sec.plainText, order: i) + verse.song = song + lyrics += " " + sec.plainText + } + } + song.searchText = Song.makeSearchText( + title: song.title, titles: song.titles, author: song.author, + authorWords: song.authorWords, songNumber: song.songNumber, + songbookNumber: song.songbookNumber, lyrics: lyrics + ) + try? modelContext.save() + } +} + +struct SectionEditorCard: View { + @Bindable var section: SongSection + var isFocused: Bool + var onFocus: () -> Void + var onMoveUp: () -> Void + var onMoveDown: () -> Void + var onDelete: () -> Void + var onDuplicate: () -> Void = {} + var onDrop: (String) -> Void = { _ in } + + @State private var chordsOverride: Bool? = nil + private var sectionHasChords: Bool { section.lines.contains { !$0.chords.isEmpty } } + private var showChords: Bool { chordsOverride ?? sectionHasChords } + + private var lyricsBinding: Binding<String> { + if showChords { + return Binding( + get: { songLinesToChordPro(section.lines) }, + set: { section.lines = songChordProToLines($0) } + ) + } + return Binding( + get: { section.plainText }, + set: { section.lines = $0.components(separatedBy: "\n").map { SongLine(text: $0) } } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + // Drag handle (reorder) + Image(systemName: "line.3.horizontal") + .font(.caption).foregroundStyle(.tertiary) + .draggable(section.id.uuidString) + .help(String(localized: "Trage pentru reordonare", comment: "Tooltip")) + + // Colored type badge + picker + Menu { + ForEach(songSectionTypes, id: \.self) { type in + Button(songTypeLabel(type)) { section.type = type } + } + } label: { + Text(songTypeLabel(section.type)) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(songTypeColor(section.type).opacity(0.2), in: Capsule()) + .foregroundStyle(songTypeColor(section.type)) + } + .menuStyle(.borderlessButton).fixedSize() + + TextField(String(localized: "Etichetă", comment: "Field"), text: $section.label) + .textFieldStyle(.plain).font(.callout.weight(.medium)) + + Spacer() + + Stepper("×\(section.repeatCount)", value: $section.repeatCount, in: 1...8) + .controlSize(.mini).fixedSize() + .help(String(localized: "De câte ori se cântă", comment: "Tooltip")) + + Button { chordsOverride = !showChords } label: { + Image(systemName: "music.note") + .foregroundStyle(showChords ? Color.accentColor : Color.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "Acorduri inline [G]", comment: "Tooltip")) + + Button(action: onMoveUp) { Image(systemName: "chevron.up") }.buttonStyle(.borderless) + Button(action: onMoveDown) { Image(systemName: "chevron.down") }.buttonStyle(.borderless) + Button(action: onDuplicate) { Image(systemName: "plus.square.on.square") } + .buttonStyle(.borderless) + .help(String(localized: "Duplică strofa", comment: "Tooltip")) + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }.buttonStyle(.borderless) + } + + TextEditor(text: lyricsBinding) + .font(showChords ? .system(.body, design: .monospaced) : .body) + .frame(minHeight: 76) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + + if showChords { + Text(String(localized: "Scrie acordurile între paranteze: [G]Mare ești [D]Tu", comment: "Hint")) + .font(.system(size: 10)).foregroundStyle(.tertiary) + } + } + .padding(10) + .background( + isFocused ? songTypeColor(section.type).opacity(0.08) : Color.secondary.opacity(0.05), + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isFocused ? songTypeColor(section.type) : .clear, lineWidth: 1.5) + ) + .overlay(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(songTypeColor(section.type)) + .frame(width: 3) + .padding(.vertical, 10) + } + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .dropDestination(for: String.self) { items, _ in + if let id = items.first { onDrop(id) } + return true + } + } +} diff --git a/TopPresenterTests/TopPresenterTests.swift b/TopPresenterTests/TopPresenterTests.swift index 18e803d..2b4a5af 100644 --- a/TopPresenterTests/TopPresenterTests.swift +++ b/TopPresenterTests/TopPresenterTests.swift @@ -578,6 +578,28 @@ struct PPTXImporterTests { #expect(result.verses[0].text.contains("La la la")) #expect(result.verses[1].text.contains("Strofa a doua")) } + + @Test func powerPointDedupsRepeatedChorusIntoArrangement() async throws { + // V1, Chorus, V2, Chorus(identical) → 3 unique sections, 4-step arrangement. + let chorus = "Slăvit să fie Domnul" + let zip = makeZip(entries: [ + ("docProps/core.xml", Data("<cp><dc:title>Test Dedup</dc:title></cp>".utf8), true), + ("ppt/slides/slide1.xml", Data(slideXML(title: nil, body: "Strofa unu aici").utf8), true), + ("ppt/slides/slide2.xml", Data(slideXML(title: nil, body: chorus).utf8), true), + ("ppt/slides/slide3.xml", Data(slideXML(title: nil, body: "Strofa doi aici").utf8), true), + ("ppt/slides/slide4.xml", Data(slideXML(title: nil, body: chorus).utf8), true), + ]) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("dedup-\(UUID().uuidString).pptx") + try zip.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try await PowerPointSongImporter().parse(fileURL: url) + let version = try #require(result.versions.first) + #expect(version.sections.count == 3) // chorus stored once + #expect(version.arrangement.count == 4) // but played 4 times + #expect(version.sections.contains { $0.type == "chorus" }) // repeated slide → chorus + } } // MARK: - Batch Song Import Tests @@ -585,7 +607,7 @@ struct PPTXImporterTests { struct SongBatchImportTests { private func makeInMemoryContext() throws -> ModelContext { let container = try ModelContainer( - for: Schema(versionedSchema: SchemaV1.self), + for: Schema(versionedSchema: SchemaV2.self), configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] ) return ModelContext(container) @@ -1635,3 +1657,260 @@ struct PresentationManagerTests { #expect(pm.outputBoxFrame(for: .verseContent) == .defaultVerse) } } + +// MARK: - GOAT Song JSON round-trip + new importers + +struct SongGoatFormatTests { + private func makeContext() throws -> ModelContext { + let container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] + ) + return ModelContext(container) + } + + @Test func roundTripsRichSongThroughGoatJSON() throws { + let ctx = try makeContext() + let collection = SongCollection(name: "T", sourceFormat: "test") + ctx.insert(collection) + let song = Song(title: "Mare ești Tu", author: "Anon", copyright: "©", ccliNumber: "123", songNumber: "7") + ctx.insert(song) + song.collection = collection + song.titles = ["How Great Thou Art"] + song.language = "ro" + song.themes = ["worship", "easter"] + song.style = "imn" + song.songbookNumber = "42" + let book = Songbook(name: "Cântările Evangheliei", publisher: "X", language: "ro", year: "1990") + ctx.insert(book) + song.songbook = book + + let v = SongVersion(name: "Clasică", order: 0, language: "ro", key: "G", capo: 2, tempo: "72", timeSignature: "4/4") + v.arrangement = ["v1", "c", "v1", "c"] + v.song = song + let s1 = SongSection(sectionKey: "v1", type: "verse", label: "Strofa 1", order: 0, lines: [ + SongLine(text: "Mare ești Tu", chords: [SongChord(sym: "G", pos: 0)], translations: ["en": "How great Thou art"]) + ]) + s1.version = v + let s2 = SongSection(sectionKey: "c", type: "chorus", label: "Refren", order: 1, lines: [ + SongLine(text: "Atunci cânt eu", chords: [SongChord(sym: "D", pos: 6)]) + ]) + s2.version = v + try ctx.save() + + let json = try ExportService.exportSongToTopPresenterJSON(song) + let results = try TopPresenterSongImporter.allResults(from: Data(json.utf8)) + // Use plain `guard let` (not #require/#expect) to unwrap the rich structs — the + // Swift Testing macros segfault copying a large optional struct here. + guard let r = results.first else { #expect(Bool(false), "no song parsed"); return } + + #expect(r.title == "Mare ești Tu") + #expect(r.titles.contains("How Great Thou Art")) + #expect(r.language == "ro") + #expect(r.themes.contains("easter")) + #expect(r.style == "imn") + let bookName = r.songbook?.name + let bookNumber = r.songbook?.number + #expect(bookName == "Cântările Evangheliei") + #expect(bookNumber == "42") + + guard let rv = r.versions.first else { #expect(Bool(false), "no version parsed"); return } + let key = rv.key + let capo = rv.capo + let arrangement = rv.arrangement + let secCount = rv.sections.count + let v1Sym = rv.sections.first { $0.sectionKey == "v1" }?.lines.first?.chords.first?.sym + let v1Trans = rv.sections.first { $0.sectionKey == "v1" }?.lines.first?.translations["en"] + #expect(key == "G") + #expect(capo == 2) + #expect(arrangement == ["v1", "c", "v1", "c"]) + #expect(secCount == 2) + #expect(v1Sym == "G") + #expect(v1Trans == "How great Thou art") + } + + @Test func roundTripsPerVersionOverrides() throws { + let container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] + ) + let ctx = ModelContext(container) + let col = SongCollection(name: "T", sourceFormat: "test"); ctx.insert(col) + let song = Song(title: "Mare ești Tu"); ctx.insert(song); song.collection = col + + let v0 = SongVersion(name: "Original", order: 0, key: "G"); v0.song = song + let s0 = SongSection(sectionKey: "v1", type: "verse", label: "Strofa 1", order: 0, lines: [SongLine(text: "Mare ești Tu")]) + s0.version = v0 + + let v1 = SongVersion(name: "Spaniolă", order: 1); v1.song = song + v1.overridesMetadata = true + v1.displayTitle = "Grande eres Tú" + v1.author = "Trad. X" + v1.language = "es" + v1.key = "A" + let s1 = SongSection(sectionKey: "v1", type: "verse", label: "Estrofa", order: 0, repeatCount: 2, lines: [SongLine(text: "Grande eres Tú")]) + s1.version = v1 + try ctx.save() + + let json = try ExportService.exportSongToTopPresenterJSON(song) + let results = try TopPresenterSongImporter.allResults(from: Data(json.utf8)) + guard let r = results.first, r.versions.count >= 2 else { #expect(Bool(false), "missing versions"); return } + + let rv1 = r.versions[1] + let overrides = rv1.overridesMetadata + let dt = rv1.displayTitle + let auth = rv1.author + let lang = rv1.language + let key = rv1.key + let rep = rv1.sections.first?.repeatCount + #expect(r.versions.count == 2) + #expect(overrides == true) + #expect(dt == "Grande eres Tú") + #expect(auth == "Trad. X") + #expect(lang == "es") + #expect(key == "A") + #expect(rep == 2) + } + + @Test func chordProParsesChordsAndSections() { + let content = """ + {title: Amazing Grace} + {artist: John Newton} + {key: G} + {start_of_verse} + [G]Amazing [G7]grace how [C]sweet the [G]sound + {end_of_verse} + {start_of_chorus} + [D]Praise the [G]Lord + {end_of_chorus} + """ + let r = ChordProImporter.parse(content: content, fallbackTitle: "x") + #expect(r.title == "Amazing Grace") + #expect(r.key == "G") + // Extract scalars before #expect (avoids the macro copying the whole struct). + let sectionCount = r.versions.first?.sections.count + let firstType = r.versions.first?.sections.first?.type + let firstLine = r.versions.first?.sections.first?.lines.first?.text + let firstChord = r.versions.first?.sections.first?.lines.first?.chords.first?.sym + let lastType = r.versions.first?.sections.last?.type + #expect(sectionCount == 2) + #expect(firstType == "verse") + #expect(firstLine == "Amazing grace how sweet the sound") + #expect(firstChord == "G") + #expect(lastType == "chorus") + } + + @Test func plainTextSplitsStanzasAndDetectsChorus() { + let content = """ + Strofa unu + linia doi + + [Chorus] + Refrenul aici + înca o linie + """ + let r = PlainTextSongImporter.parse(content: content, fallbackTitle: "Cant") + #expect(r.title == "Cant") + let sectionCount = r.versions.first?.sections.count + let firstType = r.versions.first?.sections.first?.type + let lastType = r.versions.first?.sections.last?.type + let lastLineCount = r.versions.first?.sections.last?.lines.count + #expect(sectionCount == 2) + #expect(firstType == "verse") + #expect(lastType == "chorus") + #expect(lastLineCount == 2) + } +} + +// MARK: - Recursive bulk import + duplicate→version + +struct SongBulkImportTests { + private func makeContext() throws -> ModelContext { + let container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] + ) + return ModelContext(container) + } + + private func writeSong(_ title: String, to url: URL) throws { + let xml = "<song><title>\(title)[V1]\nPrima strofa\n[C]\nRefren aici" + try Data(xml.utf8).write(to: url) + } + + @Test func recursiveImportFindsSongsInSubfolders() async throws { + let context = try makeContext() + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("bulk-\(UUID().uuidString)") + let sub = root.appendingPathComponent("Laszlo/Nesortate") + try fm.createDirectory(at: sub, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + try writeSong("Cantec Unu", to: root.appendingPathComponent("a.xml")) + try writeSong("Cantec Doi", to: sub.appendingPathComponent("b.xml")) + + let result = await ImportService.importSongItems(urls: [root], collectionName: "Bulk", modelContext: context) + #expect(result.importedTitles.count == 2) + #expect(result.collection?.songs.count == 2) + } + + @Test func duplicateImportAddsAsVersionDiacriticInsensitive() async throws { + let context = try makeContext() + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("dup-\(UUID().uuidString)") + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + try writeSong("Același Cântec", to: dir.appendingPathComponent("v1.xml")) + try writeSong("Acelasi Cantec", to: dir.appendingPathComponent("v2.xml")) // diacritic-folded match + + let result = await ImportService.importSongItems( + urls: [dir], collectionName: "Dup", modelContext: context, duplicateResolution: .addAsVersion + ) + #expect(result.collection?.songs.count == 1) + let song = try #require(result.collection?.songs.first) + #expect(song.versions.count == 2) + } + + @Test func keepBothImportsSeparately() async throws { + let context = try makeContext() + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("keep-\(UUID().uuidString)") + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + try writeSong("Egal", to: dir.appendingPathComponent("a.xml")) + try writeSong("Egal", to: dir.appendingPathComponent("b.xml")) + + let result = await ImportService.importSongItems( + urls: [dir], collectionName: "Keep", modelContext: context, duplicateResolution: .keepBoth + ) + #expect(result.collection?.songs.count == 2) + } + + @Test func slideBuilderAutoSplitsAndExpandsArrangement() throws { + let container = try ModelContainer( + for: Schema(versionedSchema: SchemaV2.self), + configurations: [ModelConfiguration(isStoredInMemoryOnly: true)] + ) + let ctx = ModelContext(container) + let song = Song(title: "X") + ctx.insert(song) + let v = SongVersion(name: "V", order: 0) + v.song = song + let s1 = SongSection(sectionKey: "v1", type: "verse", label: "Strofa 1", order: 0, + lines: (1...5).map { SongLine(text: "linia \($0)") }) + s1.version = v + let s2 = SongSection(sectionKey: "c", type: "chorus", label: "Refren", order: 1, + lines: [SongLine(text: "ref")]) + s2.version = v + v.arrangement = ["v1", "c", "v1"] // verse (splits) + chorus + verse again + try ctx.save() + + let slides = buildSongSlides(version: v, maxLines: 2, bilingual: false, language: nil) + // v1 (5 lines / 2 = 3 slides) + c (1) + v1 (3) = 7 + #expect(slides.count == 7) + #expect(slides.allSatisfy { $0.total == 7 }) + #expect(slides.first?.text.contains("linia 1") == true) + } +}