Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions TopPresenter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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;
Expand All @@ -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 = "";
Expand Down
9 changes: 9 additions & 0 deletions TopPresenter/Core/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
Expand Down
38 changes: 34 additions & 4 deletions TopPresenter/Core/DataMigration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
[]
}
}
32 changes: 32 additions & 0 deletions TopPresenter/Core/LibraryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down Expand Up @@ -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) {
Expand Down
59 changes: 52 additions & 7 deletions TopPresenter/Core/PresentationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}

Expand Down
Loading
Loading