diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cdbd9ed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.fontSize": 14 +} \ No newline at end of file diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ac9c214..dbfd96b 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3,12 +3,31 @@ "strings" : { "" : { + }, + "%@ (%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$lld)" + } + } + } + }, + "%lld" : { + + }, + "%lld Artikel in Queue" : { + }, "%lld min" : { }, "%lld Minuten" : { + }, + "%lld." : { + }, "12 min • Today • example.com" : { @@ -44,6 +63,9 @@ }, "Artikel automatisch als gelesen markieren" : { + }, + "Artikel vorlesen" : { + }, "Automatischer Sync" : { @@ -107,6 +129,9 @@ }, "Fehler" : { + }, + "Fehler: %@" : { + }, "Fertig" : { @@ -137,6 +162,9 @@ }, "Item at %@" : { + }, + "Keine Artikel in der Queue" : { + }, "Keine Bookmarks" : { @@ -266,6 +294,9 @@ }, "Version %@" : { + }, + "Vorlese-Queue" : { + }, "Vorschau" : { diff --git a/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift new file mode 100644 index 0000000..d82973c --- /dev/null +++ b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift @@ -0,0 +1,19 @@ +import Foundation + +class AddTextToSpeechQueueUseCase { + private let speechQueue: SpeechQueue + + init(speechQueue: SpeechQueue = .shared) { + self.speechQueue = speechQueue + } + + func execute(bookmarkDetail: BookmarkDetail) { + var text = bookmarkDetail.title + "\n" + if let content = bookmarkDetail.content { + text += content.stripHTML + } else { + text += bookmarkDetail.description.stripHTML + } + speechQueue.enqueue(text) + } +} diff --git a/readeck/Domain/UseCase/ReadBookmarkUseCase.swift b/readeck/Domain/UseCase/ReadBookmarkUseCase.swift new file mode 100644 index 0000000..8c64f8b --- /dev/null +++ b/readeck/Domain/UseCase/ReadBookmarkUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class ReadBookmarkUseCase { + private let addToSpeechQueue: AddTextToSpeechQueueUseCase + + init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) { + self.addToSpeechQueue = addToSpeechQueue + } + + func execute(bookmarkDetail: BookmarkDetail) { + addToSpeechQueue.execute(bookmarkDetail: bookmarkDetail) + } +} diff --git a/readeck/Info.plist b/readeck/Info.plist index cb46d58..29aeaca 100644 --- a/readeck/Info.plist +++ b/readeck/Info.plist @@ -16,9 +16,13 @@ UILaunchScreen UIColorName - green + green2 UIImageName readeck + UIBackgroundModes + + audio + diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 9948975..64381a5 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -225,6 +225,16 @@ struct BookmarkDetailView: View { .foregroundColor(.secondary) } } + + metaRow(icon: "speaker.wave.2") { + Button(action: { + viewModel.addBookmarkToSpeechQueue() + }) { + Text("Artikel vorlesen") + .font(.subheadline) + .foregroundColor(.secondary) + } + } } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index 2006c41..ba064a2 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -6,6 +6,7 @@ class BookmarkDetailViewModel { private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase private let loadSettingsUseCase: LoadSettingsUseCase private let updateBookmarkUseCase: UpdateBookmarkUseCase + private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" @@ -22,6 +23,7 @@ class BookmarkDetailViewModel { self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() + self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase() } @MainActor @@ -78,4 +80,9 @@ class BookmarkDetailViewModel { func refreshBookmarkDetail(id: String) async { await loadBookmarkDetail(id: id) } + + func addBookmarkToSpeechQueue() { + bookmarkDetail.content = articleContent + addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail) + } } diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 0900bda..c759093 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -15,42 +15,49 @@ struct PhoneTabView: View { @State private var selectedTabIndex: Int = 0 var body: some View { - TabView(selection: $selectedTabIndex) { - ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in - NavigationStack { - tabView(for: tab) - } - .tabItem { - Label(tab.label, systemImage: tab.systemImage) - } - .tag(idx) - } - - NavigationStack { - List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in - NavigationLink(tag: tab, selection: $selectedMoreTab) { + GlobalPlayerContainerView { + TabView(selection: $selectedTabIndex) { + ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in + NavigationStack { tabView(for: tab) - .navigationTitle(tab.label) - } label: { + } + .tabItem { Label(tab.label, systemImage: tab.systemImage) } - .listRowBackground(Color(R.color.bookmark_list_bg)) + .tag(idx) } - .navigationTitle("Mehr") - .scrollContentBackground(.hidden) - .background(Color(R.color.bookmark_list_bg)) - } - .tabItem { - Label("Mehr", systemImage: "ellipsis") - } - .tag(mainTabs.count) - .onAppear { - if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { - selectedMoreTab = nil + + NavigationStack { + if let selectedTab = selectedMoreTab { + tabView(for: selectedTab) + .navigationTitle(selectedTab.label) + } else { + List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in + NavigationLink { + tabView(for: tab) + .navigationTitle(tab.label) + } label: { + Label(tab.label, systemImage: tab.systemImage) + } + .listRowBackground(Color(R.color.bookmark_list_bg)) + } + .navigationTitle("Mehr") + .scrollContentBackground(.hidden) + .background(Color(R.color.bookmark_list_bg)) + } + } + .tabItem { + Label("Mehr", systemImage: "ellipsis") + } + .tag(mainTabs.count) + .onAppear { + if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { + selectedMoreTab = nil + } } } + .accentColor(.accentColor) } - .accentColor(.accentColor) } @ViewBuilder @@ -75,7 +82,7 @@ struct PhoneTabView: View { case .pictures: BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil)) case .tags: - Text("Tags") + LabelsView() } } } diff --git a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift new file mode 100644 index 0000000..f5f69ce --- /dev/null +++ b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct GlobalPlayerContainerView: View { + let content: Content + @State private var speechQueue = SpeechQueue.shared + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ZStack(alignment: .bottom) { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if speechQueue.hasItems { + VStack(spacing: 0) { + SpeechPlayerView() + .padding(.horizontal, 16) + .padding(.vertical, 8) + .transition(.move(edge: .bottom).combined(with: .opacity)) + + Rectangle() + .fill(.clear) + .frame(height: 49) + } + } + } + .animation(.spring(), value: speechQueue.hasItems) + } +} + +#Preview { + GlobalPlayerContainerView { + Text("Main Content") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } +} diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift new file mode 100644 index 0000000..16b7707 --- /dev/null +++ b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct SpeechPlayerView: View { + @State var viewModel = SpeechPlayerViewModel() + @State private var isExpanded = false + @State private var dragOffset: CGFloat = 0 + @State private var speechQueue = SpeechQueue.shared + @State private var ttsManager = TTSManager.shared + + private let minHeight: CGFloat = 60 + private let maxHeight: CGFloat = 300 + + var body: some View { + VStack(spacing: 0) { + if isExpanded { + expandedView + } else { + collapsedView + } + } + .frame(height: isExpanded ? maxHeight : minHeight) + .background(.ultraThinMaterial) + .cornerRadius(12) + .shadow(radius: 8, x: 0, y: -2) + .offset(y: dragOffset) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation.height + } + .onEnded { value in + withAnimation(.spring()) { + if value.translation.height < -50 && !isExpanded { + isExpanded = true + } else if value.translation.height > 50 && isExpanded { + isExpanded = false + } + dragOffset = 0 + } + } + ) + } + + private var collapsedView: some View { + HStack(spacing: 16) { + // Play/Pause Button + Button(action: { + if ttsManager.isCurrentlySpeaking() { + viewModel.pause() + } else { + viewModel.resume() + } + }) { + Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill") + .font(.title2) + .foregroundColor(.accentColor) + } + + // Current Text + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + + if speechQueue.queueCount > 0 { + Text("\(speechQueue.queueCount) Artikel in Queue") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Stop Button + Button(action: { + viewModel.stop() + }) { + Image(systemName: "stop.fill") + .font(.title3) + .foregroundColor(.secondary) + } + + // Expand Button + Button(action: { + withAnimation(.spring()) { + isExpanded.toggle() + } + }) { + Image(systemName: "chevron.up") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var expandedView: some View { + VStack(spacing: 16) { + // Header + HStack { + Text("Vorlese-Queue") + .font(.headline) + .fontWeight(.semibold) + + Spacer() + + Button(action: { + withAnimation(.spring()) { + isExpanded = false + } + }) { + Image(systemName: "chevron.down") + .font(.title3) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + + // Controls + HStack(spacing: 24) { + Button(action: { + if ttsManager.isCurrentlySpeaking() { + viewModel.pause() + } else { + viewModel.resume() + } + }) { + Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill") + .font(.title) + .foregroundColor(.accentColor) + } + + Button(action: { + viewModel.stop() + }) { + Image(systemName: "stop.fill") + .font(.title2) + .foregroundColor(.secondary) + } + } + + // Queue List + if speechQueue.queueCount == 0 { + Text("Keine Artikel in der Queue") + .font(.subheadline) + .foregroundColor(.secondary) + .padding() + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(Array(speechQueue.queueItems.enumerated()), id: \.offset) { index, item in + HStack { + Text("\(index + 1).") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 20, alignment: .leading) + + Text(item) + .font(.subheadline) + .lineLimit(2) + .truncationMode(.tail) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + .padding(.horizontal, 16) + } + } + + Spacer() + } + } +} + +#Preview { + SpeechPlayerView() +} diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift new file mode 100644 index 0000000..fe5d1da --- /dev/null +++ b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift @@ -0,0 +1,24 @@ +import Foundation + +@Observable +class SpeechPlayerViewModel { + var isSpeaking: Bool { + return TTSManager.shared.isCurrentlySpeaking() + } + + var currentText: String { + return SpeechQueue.shared.currentText + } + + func pause() { + TTSManager.shared.pause() + } + + func resume() { + TTSManager.shared.resume() + } + + func stop() { + SpeechQueue.shared.clear() + } +} diff --git a/readeck/Utils/SpeechQueue.swift b/readeck/Utils/SpeechQueue.swift new file mode 100644 index 0000000..4dc66c8 --- /dev/null +++ b/readeck/Utils/SpeechQueue.swift @@ -0,0 +1,74 @@ +import Foundation + +@Observable +class SpeechQueue { + private var queue: [String] = [] + private var isProcessing = false + private let ttsManager: TTSManager + private let language: String + + static let shared = SpeechQueue() + + var hasItems: Bool { + return !queue.isEmpty || ttsManager.isCurrentlySpeaking() + } + + var queueCount: Int { + return queue.count + } + + var currentItem: String? { + return queue.first + } + + var queueItems: [String] { + return queue + } + + var currentText: String { + return queue.first ?? "" + } + + private init(ttsManager: TTSManager = .shared, language: String = "de-DE") { + self.ttsManager = ttsManager + self.language = language + } + + func enqueue(_ text: String) { + queue.append(text) + processQueue() + } + + func enqueue(contentsOf texts: [String]) { + queue.append(contentsOf: texts) + processQueue() + } + + func clear() { + queue.removeAll() + ttsManager.stop() + isProcessing = false + } + + private func processQueue() { + guard !isProcessing, !queue.isEmpty else { return } + isProcessing = true + let next = queue.removeFirst() + ttsManager.speak(text: next, language: language) + // Delegate/Notification für didFinish kann hier angebunden werden + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.waitForSpeechToFinish() + } + } + + private func waitForSpeechToFinish() { + if ttsManager.isCurrentlySpeaking() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.waitForSpeechToFinish() + } + } else { + self.isProcessing = false + self.processQueue() + } + } +} diff --git a/readeck/Utils/StringExtensions.swift b/readeck/Utils/StringExtensions.swift new file mode 100644 index 0000000..1c71de0 --- /dev/null +++ b/readeck/Utils/StringExtensions.swift @@ -0,0 +1,29 @@ +import Foundation + +extension String { + var stripHTML: String { + // Entfernt HTML-Tags und decodiert HTML-Entities + let attributedString = try? NSAttributedString( + data: Data(utf8), + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) + + return attributedString?.string ?? self + } + + var stripHTMLSimple: String { + // Einfache Regex-basierte HTML-Entfernung + return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} \ No newline at end of file diff --git a/readeck/Utils/TTSManager.swift b/readeck/Utils/TTSManager.swift new file mode 100644 index 0000000..d5f4c78 --- /dev/null +++ b/readeck/Utils/TTSManager.swift @@ -0,0 +1,165 @@ +import Foundation +import UIKit +import AVFoundation + +class TTSManager: NSObject, AVSpeechSynthesizerDelegate { + static let shared = TTSManager() + private let synthesizer = AVSpeechSynthesizer() + private var isSpeaking = false + + override private init() { + super.init() + synthesizer.delegate = self + configureAudioSession() + } + + private func configureAudioSession() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP]) + try audioSession.setActive(true) + + // Background-Audio aktivieren + try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP]) + + // Notification für App-Lifecycle + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } catch { + print("Fehler beim Konfigurieren der Audio-Session: \(error)") + } + } + + func speak(text: String, language: String = "de-DE") { + guard !text.isEmpty else { return } + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + let utterance = AVSpeechUtterance(string: text) + + // Versuche eine hochwertige Stimme zu finden + if let enhancedVoice = findEnhancedVoice(for: language) { + utterance.voice = enhancedVoice + } else { + utterance.voice = AVSpeechSynthesisVoice(language: language) + } + + utterance.rate = 0.5 + utterance.pitchMultiplier = 1.0 + utterance.volume = 1.0 + + synthesizer.speak(utterance) + isSpeaking = true + } + + private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice? { + let availableVoices = AVSpeechSynthesisVoice.speechVoices() + + // Bevorzugte Stimmen für alle Sprachen + let preferredVoiceNames = [ + "Anna", // Deutsche Premium-Stimme + "Helena", // Deutsche Premium-Stimme + "Siri", // Siri-Stimme (falls verfügbar) + "Enhanced", // Enhanced-Stimmen + "Karen", // Englische Premium-Stimme + "Daniel", // Englische Premium-Stimme + "Marie", // Französische Premium-Stimme + "Paolo", // Italienische Premium-Stimme + "Carmen", // Spanische Premium-Stimme + "Yuki" // Japanische Premium-Stimme + ] + + // Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen + for voiceName in preferredVoiceNames { + if let voice = availableVoices.first(where: { + $0.language == language && + $0.name.contains(voiceName) + }) { + return voice + } + } + + // Fallback: Erste verfügbare Stimme für die Sprache + return availableVoices.first(where: { $0.language == language }) + } + + func pause() { + synthesizer.pauseSpeaking(at: .word) + } + + func resume() { + synthesizer.continueSpeaking() + } + + func stop() { + synthesizer.stopSpeaking(at: .immediate) + isSpeaking = false + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + isSpeaking = false + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + isSpeaking = false + } + + func isCurrentlySpeaking() -> Bool { + return synthesizer.isSpeaking + } + + @objc private func handleAppDidEnterBackground() { + // App geht in Hintergrund - Audio-Session beibehalten + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Fehler beim Aktivieren der Audio-Session im Hintergrund: \(error)") + } + } + + @objc private func handleAppWillEnterForeground() { + // App kommt in Vordergrund - Audio-Session erneuern + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Fehler beim Aktivieren der Audio-Session im Vordergrund: \(error)") + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // Debug-Methode: Zeigt alle verfügbaren Stimmen für eine Sprache + func printAvailableVoices(for language: String = "de-DE") { + let voices = AVSpeechSynthesisVoice.speechVoices() + let filteredVoices = voices.filter { $0.language.starts(with: language.prefix(2)) } + + print("Verfügbare Stimmen für \(language):") + for voice in filteredVoices { + print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)") + } + } + + // Debug-Methode: Zeigt alle verfügbaren Sprachen + func printAllAvailableLanguages() { + let voices = AVSpeechSynthesisVoice.speechVoices() + let languages = Set(voices.map { $0.language }) + + print("Verfügbare Sprachen:") + for language in languages.sorted() { + print("- \(language)") + } + } +} diff --git a/readeckTests/StringExtensionsTests.swift b/readeckTests/StringExtensionsTests.swift new file mode 100644 index 0000000..257c293 --- /dev/null +++ b/readeckTests/StringExtensionsTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import readeck + +final class StringExtensionsTests: XCTestCase { + + // MARK: - stripHTML Tests + + func testStripHTML_SimpleTags() { + let html = "

Dies ist ein wichtiger Artikel.

" + let expected = "Dies ist ein wichtiger Artikel.\n" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_ComplexNestedTags() { + let html = "

Titel

Text mit kursiv und fett.

" + let expected = "Titel\nText mit kursiv und fett." + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_WithAttributes() { + let html = "

Text mit Attributen

" + let expected = "Text mit Attributen\n" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_EmptyTags() { + let html = "

Inhalt
" + let expected = "\nInhalt\n" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_SelfClosingTags() { + let html = "

Text mit
Zeilenumbruch und Bild.

" + let expected = "Text mit \nZeilenumbruch und Bild.\n" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_NoTags() { + let plainText = "Dies ist normaler Text ohne HTML." + + XCTAssertEqual(plainText.stripHTML, plainText) + } + + func testStripHTML_EmptyString() { + let emptyString = "" + + XCTAssertEqual(emptyString.stripHTML, emptyString) + } + + func testStripHTML_OnlyTags() { + let onlyTags = "

" + let expected = "\n" + + XCTAssertEqual(onlyTags.stripHTML, expected) + } + + // MARK: - stripHTMLSimple Tests + + func testStripHTMLSimple_BasicTags() { + let html = "

Text mit fett.

" + let expected = "Text mit fett." + + XCTAssertEqual(html.stripHTMLSimple, expected) + } + + func testStripHTMLSimple_HTMLEntities() { + let html = "

Text mit  Leerzeichen, & Zeichen und "Anführungszeichen".

" + let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"." + + XCTAssertEqual(html.stripHTMLSimple, expected) + } + + func testStripHTMLSimple_MoreEntities() { + let html = "

<Tag> und 'Apostroph'

" + let expected = " und 'Apostroph'" + + XCTAssertEqual(html.stripHTMLSimple, expected) + } + + func testStripHTMLSimple_ComplexHTML() { + let html = "

Überschrift

Absatz mit kursiv und fett.

  • Liste 1
  • Liste 2
" + let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2" + + XCTAssertEqual(html.stripHTMLSimple, expected) + } + + func testStripHTMLSimple_NoTags() { + let plainText = "Normaler Text ohne HTML." + + XCTAssertEqual(plainText.stripHTMLSimple, plainText) + } + + func testStripHTMLSimple_EmptyString() { + let emptyString = "" + + XCTAssertEqual(emptyString.stripHTMLSimple, emptyString) + } + + func testStripHTMLSimple_WhitespaceHandling() { + let html = "

Text mit Whitespace

" + let expected = "Text mit Whitespace" + + XCTAssertEqual(html.stripHTMLSimple, expected) + } + + // MARK: - Performance Tests + + func testStripHTML_Performance() { + let largeHTML = String(repeating: "

Dies ist ein Test mit vielen HTML Tags.

", count: 1000) + + measure { + _ = largeHTML.stripHTML + } + } + + func testStripHTMLSimple_Performance() { + let largeHTML = String(repeating: "

Dies ist ein Test mit vielen HTML Tags.

", count: 1000) + + measure { + _ = largeHTML.stripHTMLSimple + } + } + + // MARK: - Edge Cases + + func testStripHTML_MalformedHTML() { + let malformed = "

Unvollständiger Tag" + let expected = "Unvollständiger Tag" + + XCTAssertEqual(malformed.stripHTML, expected) + } + + func testStripHTML_UnicodeCharacters() { + let html = "

Text mit Umlauten: äöüß und Emojis: 🚀📱

" + let expected = "Text mit Umlauten: äöüß und Emojis: 🚀📱" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_Newlines() { + let html = "

Erste Zeile
Zweite Zeile

" + let expected = "Erste Zeile\nZweite Zeile" + + XCTAssertEqual(html.stripHTML, expected) + } + + func testStripHTML_ListItems() { + let html = "
  • Erster Punkt
  • Zweiter Punkt
  • Dritter Punkt
" + let expected = "Erster Punkt\nZweiter Punkt\nDritter Punkt" + + XCTAssertEqual(html.stripHTML, expected) + } +}