diff --git a/bookstax.xcodeproj/project.pbxproj b/bookstax.xcodeproj/project.pbxproj index dfd1131..d0f34b7 100644 --- a/bookstax.xcodeproj/project.pbxproj +++ b/bookstax.xcodeproj/project.pbxproj @@ -7,13 +7,42 @@ objects = { /* Begin PBXBuildFile section */ + 049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; }; + 23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */; }; 26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26FD17072F8A9643006E87F3 /* Donations.storekit */; }; + 2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; }; + 57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; }; + 6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */; }; + 84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AC406884F8446C6F4FA215 /* DTOTests.swift */; }; + C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0841C05048CA5AE635439A8 /* Foundation.framework */; }; + DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E478C272640163A74D17B3DE /* DonationServiceTests.swift */; }; + EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2480561934949230710825EA /* StringHTMLTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 261299D52F6C686D00EC1C97; + remoteInfo = bookstax; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = ""; }; + 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = ""; }; + 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = ""; }; + 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = bookstaxTests.xctest; path = .xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2480561934949230710825EA /* StringHTMLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StringHTMLTests.swift; path = bookstaxTests/StringHTMLTests.swift; sourceTree = ""; }; 261299D62F6C686D00EC1C97 /* bookstax.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bookstax.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = ""; }; 26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = ""; }; + 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = ""; }; + 57AC406884F8446C6F4FA215 /* DTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DTOTests.swift; path = bookstaxTests/DTOTests.swift; sourceTree = ""; }; + 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIErrorTests.swift; path = bookstaxTests/APIErrorTests.swift; sourceTree = ""; }; + C0841C05048CA5AE635439A8 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -25,6 +54,14 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 1A1B96526910505E82E2CFDB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 261299D32F6C686D00EC1C97 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -35,6 +72,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1BB5D3095A0460024F7BA321 /* iOS */ = { + isa = PBXGroup; + children = ( + C0841C05048CA5AE635439A8 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 261299CD2F6C686D00EC1C97 = { isa = PBXGroup; children = ( @@ -42,6 +87,8 @@ 261299D82F6C686D00EC1C97 /* bookstax */, 261299D72F6C686D00EC1C97 /* Products */, 26FD17072F8A9643006E87F3 /* Donations.storekit */, + EB2578937899373803DA341A /* Frameworks */, + 46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */, ); sourceTree = ""; }; @@ -49,10 +96,34 @@ isa = PBXGroup; children = ( 261299D62F6C686D00EC1C97 /* bookstax.app */, + 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */, ); name = Products; sourceTree = ""; }; + 46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */ = { + isa = PBXGroup; + children = ( + 2480561934949230710825EA /* StringHTMLTests.swift */, + 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */, + 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */, + 57AC406884F8446C6F4FA215 /* DTOTests.swift */, + 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */, + 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */, + 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */, + E478C272640163A74D17B3DE /* DonationServiceTests.swift */, + ); + name = bookstaxTests; + sourceTree = ""; + }; + EB2578937899373803DA341A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1BB5D3095A0460024F7BA321 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -72,12 +143,28 @@ 261299D82F6C686D00EC1C97 /* bookstax */, ); name = bookstax; - packageProductDependencies = ( - ); productName = bookstax; productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */; productType = "com.apple.product-type.application"; }; + AD8774751A52779622D7AED5 /* bookstaxTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */; + buildPhases = ( + 67E32E036FC96F91F25C740D /* Sources */, + 1A1B96526910505E82E2CFDB /* Frameworks */, + AA28FE166C71A3A60AC62034 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 90647D0E4313E7A718C1C384 /* PBXTargetDependency */, + ); + name = bookstaxTests; + productName = bookstaxTests; + productReference = 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -101,6 +188,7 @@ Base, de, es, + fr, ); mainGroup = 261299CD2F6C686D00EC1C97; minimizedProjectReferenceProxies = 1; @@ -110,6 +198,7 @@ projectRoot = ""; targets = ( 261299D52F6C686D00EC1C97 /* bookstax */, + AD8774751A52779622D7AED5 /* bookstaxTests */, ); }; /* End PBXProject section */ @@ -123,6 +212,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA28FE166C71A3A60AC62034 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -133,9 +229,49 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 67E32E036FC96F91F25C740D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */, + 049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */, + 6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */, + 84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */, + 23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */, + 2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */, + 57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */, + DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = bookstax; + target = 261299D52F6C686D00EC1C97 /* bookstax */; + targetProxy = 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 1C68E5D77B468BD3A7F1C349 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 261299DF2F6C686E00EC1C97 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -268,11 +404,12 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -299,11 +436,12 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -319,6 +457,21 @@ }; name = Release; }; + C9DF29CF9FF31B97AC4E31E5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -340,6 +493,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1C68E5D77B468BD3A7F1C349 /* Release */, + C9DF29CF9FF31B97AC4E31E5 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 261299CE2F6C686D00EC1C97 /* Project object */; diff --git a/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme index 819920a..0b68053 100644 --- a/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme +++ b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:bookstax.xcodeproj"> + + + + + shouldAutocreateTestPlan = "NO"> + + + + + + ? private static let historyDefaultsKey = "bookstax.donationHistory" + private static let nudgeDateKey = "bookstax.lastNudgeDate" + private static let installDateKey = "bookstax.installDate" + /// ~6 months in seconds + private static let nudgeIntervalSeconds: TimeInterval = 182 * 24 * 3600 + /// 3-day grace period after install before first nudge + private static let gracePeriodSeconds: TimeInterval = 3 * 24 * 3600 private init() { donationHistory = Self.loadPersistedHistory() transactionListenerTask = startTransactionListener() + // Record install date on first launch + if UserDefaults.standard.object(forKey: Self.installDateKey) == nil { + UserDefaults.standard.set(Date(), forKey: Self.installDateKey) + } + } + + // MARK: - Nudge & Supporter State + + /// True if the user has completed at least one donation. + var hasEverDonated: Bool { + !donationHistory.isEmpty + } + + /// True if the nudge sheet should be presented. + /// Returns false immediately once any donation has been made. + var shouldShowNudge: Bool { + guard !hasEverDonated else { return false } + if let last = UserDefaults.standard.object(forKey: Self.nudgeDateKey) as? Date { + return Date().timeIntervalSince(last) >= Self.nudgeIntervalSeconds + } + // Never shown before — only show after 3-day grace period + guard let installDate = UserDefaults.standard.object(forKey: Self.installDateKey) as? Date else { + return false + } + return Date().timeIntervalSince(installDate) >= Self.gracePeriodSeconds + } + + /// Call when the nudge sheet is dismissed so we know when to show it next. + func recordNudgeSeen() { + UserDefaults.standard.set(Date(), forKey: Self.nudgeDateKey) } // MARK: - Product Loading diff --git a/bookstax/Services/LanguageManager.swift b/bookstax/Services/LanguageManager.swift index 9171d6e..7470cbc 100644 --- a/bookstax/Services/LanguageManager.swift +++ b/bookstax/Services/LanguageManager.swift @@ -1,59 +1,6 @@ import Foundation -import Observation -/// Manages in-app language selection independently of the system locale. -@Observable -final class LanguageManager { - - static let shared = LanguageManager() - - enum Language: String, CaseIterable, Identifiable { - case english = "en" - case german = "de" - case spanish = "es" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .english: return "English" - case .german: return "Deutsch" - case .spanish: return "Español" - } - } - - var flag: String { - switch self { - case .english: return "🇬🇧" - case .german: return "🇩🇪" - case .spanish: return "🇪🇸" - } - } - } - - private(set) var current: Language - - private init() { - let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? "" - current = Language(rawValue: saved) ?? .english - } - - func set(_ language: Language) { - current = language - UserDefaults.standard.set(language.rawValue, forKey: "appLanguage") - } - - /// Returns the localised string for key in the currently selected language. - func string(_ key: String) -> String { - guard let path = Bundle.main.path(forResource: current.rawValue, ofType: "lproj"), - let bundle = Bundle(path: path) else { - return NSLocalizedString(key, comment: "") - } - return bundle.localizedString(forKey: key, value: key, table: nil) - } -} - -/// Convenience shorthand +/// Returns the localized string for the given key using the device system language. func L(_ key: String) -> String { - LanguageManager.shared.string(key) + NSLocalizedString(key, comment: "") } diff --git a/bookstax/ViewModels/OnboardingViewModel.swift b/bookstax/ViewModels/OnboardingViewModel.swift index 73c8c7e..f59d538 100644 --- a/bookstax/ViewModels/OnboardingViewModel.swift +++ b/bookstax/ViewModels/OnboardingViewModel.swift @@ -5,11 +5,10 @@ import Observation @Observable final class OnboardingViewModel { - enum Step: Int, CaseIterable, Hashable { - case language = 0 - case welcome = 1 - case connect = 2 - case ready = 3 + enum Step: Hashable { + case welcome + case connect + case ready } // Navigation — NavigationStack path (language is the root, not in the path) diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift index 1b32bce..674444e 100644 --- a/bookstax/Views/Editor/PageEditorView.swift +++ b/bookstax/Views/Editor/PageEditorView.swift @@ -371,7 +371,7 @@ struct PageEditorView: View { private func replace(in tv: UITextView, range: NSRange, with string: String, cursorOffset: Int? = nil, selectRange: NSRange? = nil) { guard let swiftRange = Range(range, in: tv.text) else { return } - var newText = tv.text! + var newText = tv.text ?? "" newText.replaceSubrange(swiftRange, with: string) tv.text = newText viewModel.markdownContent = newText @@ -611,11 +611,16 @@ struct FormatButton: View { struct MarkdownPreviewView: View { let markdown: String - @State private var webPage = WebPage() + @State private var htmlContent: String = "" @Environment(\.colorScheme) private var colorScheme + private var serverBaseURL: URL { + UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) } + ?? URL(string: "about:blank")! + } + var body: some View { - WebView(webPage) + HTMLWebView(html: htmlContent, baseURL: serverBaseURL, openLinksExternally: false) .onAppear { loadPreview() } .onChange(of: markdown) { loadPreview() } .onChange(of: colorScheme) { loadPreview() } @@ -628,7 +633,7 @@ struct MarkdownPreviewView: View { let fg = isDark ? "#f2f2f7" : "#000000" let codeBg = isDark ? "#2c2c2e" : "#f2f2f7" - let fullHTML = """ + htmlContent = """ @@ -644,10 +649,6 @@ struct MarkdownPreviewView: View { \(html) """ - // Use the real server URL as base so WKWebView permits loading images from it. - let serverBase = UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) } - ?? URL(string: "about:blank")! - webPage.load(html: fullHTML, baseURL: serverBase) } /// Minimal Markdown → HTML converter for preview purposes. diff --git a/bookstax/Views/Library/BookDetailView.swift b/bookstax/Views/Library/BookDetailView.swift index 4c5c803..b3648cf 100644 --- a/bookstax/Views/Library/BookDetailView.swift +++ b/bookstax/Views/Library/BookDetailView.swift @@ -172,15 +172,7 @@ struct BookDetailView: View { NavigationLink(value: page) { Label(L("book.open"), systemImage: "arrow.up.right.square") } - Button { - let url = "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)" - let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) - if let window = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first?.keyWindow { - window.rootViewController?.present(activity, animated: true) - } - } label: { + ShareLink(item: "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)") { Label(L("book.sharelink"), systemImage: "square.and.arrow.up") } Divider() diff --git a/bookstax/Views/MainTabView.swift b/bookstax/Views/MainTabView.swift index 04e7bd8..74512d3 100644 --- a/bookstax/Views/MainTabView.swift +++ b/bookstax/Views/MainTabView.swift @@ -3,25 +3,23 @@ import SwiftUI struct MainTabView: View { @Environment(ConnectivityMonitor.self) private var connectivity @State private var selectedTab = 0 + @State private var showNudge = false private let navState = AppNavigationState.shared var body: some View { TabView(selection: $selectedTab) { - Tab(L("tab.library"), systemImage: "books.vertical", value: 0) { - LibraryView() - } - - Tab(L("tab.quicknote"), systemImage: "square.and.pencil", value: 1) { - QuickNoteView() - } - - Tab(L("tab.search"), systemImage: "magnifyingglass", value: 2) { - SearchView() - } - - Tab(L("tab.settings"), systemImage: "gear", value: 3) { - SettingsView() - } + LibraryView() + .tabItem { Label(L("tab.library"), systemImage: "books.vertical") } + .tag(0) + QuickNoteView() + .tabItem { Label(L("tab.quicknote"), systemImage: "square.and.pencil") } + .tag(1) + SearchView() + .tabItem { Label(L("tab.search"), systemImage: "magnifyingglass") } + .tag(2) + SettingsView() + .tabItem { Label(L("tab.settings"), systemImage: "gear") } + .tag(3) } .onChange(of: navState.pendingBookNavigation) { _, book in if book != nil { selectedTab = 0 } @@ -29,6 +27,18 @@ struct MainTabView: View { .onChange(of: navState.navigateToSettings) { _, go in if go { selectedTab = 3; navState.navigateToSettings = false } } + .sheet(isPresented: $showNudge, onDismiss: { + DonationService.shared.recordNudgeSeen() + }) { + SupportNudgeSheet(isPresented: $showNudge) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .task { + // Small delay so the app settles before the sheet appears + try? await Task.sleep(for: .seconds(2)) + showNudge = DonationService.shared.shouldShowNudge + } } } diff --git a/bookstax/Views/Onboarding/OnboardingView.swift b/bookstax/Views/Onboarding/OnboardingView.swift index 884c0f7..17c7597 100644 --- a/bookstax/Views/Onboarding/OnboardingView.swift +++ b/bookstax/Views/Onboarding/OnboardingView.swift @@ -4,7 +4,6 @@ import SwiftUI struct OnboardingView: View { @State private var viewModel = OnboardingViewModel() - @State private var langManager = LanguageManager.shared var body: some View { Group { @@ -12,7 +11,7 @@ struct OnboardingView: View { Color.clear } else { NavigationStack(path: $viewModel.navPath) { - LanguageStepView(viewModel: viewModel) + WelcomeStepView(viewModel: viewModel) .navigationDestination(for: OnboardingViewModel.Step.self) { step in switch step { case .welcome: @@ -21,102 +20,12 @@ struct OnboardingView: View { ConnectStepView(viewModel: viewModel) case .ready: ReadyStepView(onComplete: viewModel.completeOnboarding) - case .language: - EmptyView() } } - .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) } } } - .environment(langManager) - } -} - -// MARK: - Step 0: Language - -struct LanguageStepView: View { - @Bindable var viewModel: OnboardingViewModel - @State private var selected: LanguageManager.Language = LanguageManager.shared.current - - var body: some View { - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.accentColor.opacity(0.12)) - .frame(width: 120, height: 120) - Image(systemName: "globe") - .font(.system(size: 52)) - .foregroundStyle(Color.accentColor) - } - - VStack(spacing: 8) { - Text(L("onboarding.language.title")) - .font(.largeTitle.bold()) - .multilineTextAlignment(.center) - - Text(L("onboarding.language.subtitle")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - } - - VStack(spacing: 12) { - ForEach(LanguageManager.Language.allCases) { lang in - Button { - selected = lang - LanguageManager.shared.set(lang) - } label: { - HStack(spacing: 14) { - Text(lang.flag) - .font(.title2) - Text(lang.displayName) - .font(.body.weight(.medium)) - .foregroundStyle(.primary) - Spacer() - if selected == lang { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - } - } - .padding() - .background( - selected == lang - ? Color.accentColor.opacity(0.1) - : Color(.secondarySystemBackground), - in: RoundedRectangle(cornerRadius: 12) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(selected == lang ? Color.accentColor : Color.clear, lineWidth: 1.5) - ) - } - .accessibilityLabel(lang.displayName) - } - } - .padding(.horizontal, 32) - - Spacer() - - Button { - viewModel.push(.welcome) - } label: { - Text(L("onboarding.welcome.cta")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - .padding(.horizontal, 32) - .padding(.bottom, 48) - } - .padding() } } diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift index ff5473e..e7c6785 100644 --- a/bookstax/Views/Reader/PageReaderView.swift +++ b/bookstax/Views/Reader/PageReaderView.swift @@ -1,9 +1,8 @@ import SwiftUI -import WebKit struct PageReaderView: View { let page: PageDTO - @State private var webPage = WebPage() + @State private var htmlContent: String = "" @State private var fullPage: PageDTO? = nil @State private var isLoadingPage = false @State private var comments: [CommentDTO] = [] @@ -43,7 +42,7 @@ struct PageReaderView: View { } // Web content — fills all space not taken by the comments inset - WebView(webPage) + HTMLWebView(html: htmlContent, baseURL: URL(string: serverURL)) .frame(maxWidth: .infinity, maxHeight: .infinity) } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -96,15 +95,7 @@ struct PageReaderView: View { .accessibilityLabel(L("reader.edit")) } ToolbarItem(placement: .topBarTrailing) { - Button { - let url = "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)" - let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) - if let window = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first?.keyWindow { - window.rootViewController?.present(activity, animated: true) - } - } label: { + ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") { Image(systemName: "square.and.arrow.up") } .accessibilityLabel(L("reader.share")) @@ -243,8 +234,7 @@ struct PageReaderView: View { } private func loadContent() { - let html = buildHTML(content: resolvedPage.html ?? "

\(L("reader.nocontent"))

") - webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!) + htmlContent = buildHTML(content: resolvedPage.html ?? "

\(L("reader.nocontent"))

") } private func loadComments() async { diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift index 0bbf040..2709b31 100644 --- a/bookstax/Views/Settings/SettingsView.swift +++ b/bookstax/Views/Settings/SettingsView.swift @@ -8,19 +8,20 @@ struct SettingsView: View { @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue @AppStorage("loggingEnabled") private var loggingEnabled = false @Environment(ServerProfileStore.self) private var profileStore + @State private var donationService = DonationService.shared private var selectedTheme: AccentTheme { AccentTheme(rawValue: accentThemeRaw) ?? .ocean } @State private var showSignOutAlert = false @State private var showSafari: URL? = nil - @State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current @State private var showLogViewer = false @State private var shareItems: [Any]? = nil @State private var showAddServer = false @State private var profileToSwitch: ServerProfile? = nil @State private var profileToDelete: ServerProfile? = nil @State private var profileToEdit: ServerProfile? = nil + @State private var showCacheClearedAlert = false private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" @@ -28,29 +29,6 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { - // Language section - Section { - ForEach(LanguageManager.Language.allCases) { lang in - Button { - selectedLanguage = lang - LanguageManager.shared.set(lang) - } label: { - HStack { - Text(lang.flag) - Text(lang.displayName) - .foregroundStyle(.primary) - Spacer() - if selectedLanguage == lang { - Image(systemName: "checkmark") - .foregroundStyle(.blue) - } - } - } - } - } header: { - Text(L("settings.language.header")) - } - // Appearance section Section(L("settings.appearance")) { Picker(L("settings.appearance.theme"), selection: $appTheme) { @@ -180,6 +158,27 @@ struct SettingsView: View { } } + // Data section + Section { + Button(role: .destructive) { + URLCache.shared.removeAllCachedResponses() + showCacheClearedAlert = true + } label: { + Label(L("settings.data.clearcache"), systemImage: "trash") + } + } header: { + Text(L("settings.data")) + } footer: { + Text(L("settings.data.clearcache.footer")) + } + + // Supporter badge — only visible after a donation + if donationService.hasEverDonated { + Section { + SupporterBadgeRow() + } + } + // Donate section DonationSectionView() @@ -253,6 +252,9 @@ struct SettingsView: View { Text(String(format: L("settings.servers.delete.active.message"), p.name)) } } + .alert(L("settings.data.clearcache.done"), isPresented: $showCacheClearedAlert) { + Button(L("common.ok"), role: .cancel) {} + } .sheet(isPresented: $showAddServer) { AddServerView() } .sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) } .sheet(item: $showSafari) { url in diff --git a/bookstax/Views/Settings/SupportNudgeSheet.swift b/bookstax/Views/Settings/SupportNudgeSheet.swift new file mode 100644 index 0000000..ca22eba --- /dev/null +++ b/bookstax/Views/Settings/SupportNudgeSheet.swift @@ -0,0 +1,198 @@ +import SwiftUI +import StoreKit + +// MARK: - SupportNudgeSheet + +/// Modal sheet that surfaces the donation options and encourages the user +/// to support active development. Shown at most every 6 months. +struct SupportNudgeSheet: View { + @Binding var isPresented: Bool + @State private var service = DonationService.shared + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 28) { + headerIcon + .padding(.top, 40) + + textBlock + + productList + + Button { + isPresented = false + } label: { + Text(L("nudge.dismiss")) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } + .padding(.bottom, 32) + } + .padding(.horizontal, 24) + } + } + .task { await service.loadProducts() } + .onChange(of: service.purchaseState) { _, state in + // Auto-close after the thank-you moment + if case .thankYou = state { + Task { + try? await Task.sleep(for: .seconds(2)) + isPresented = false + } + } + } + } + + // MARK: - Subviews + + private var headerIcon: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.pink.opacity(0.18), Color.orange.opacity(0.12)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 112, height: 112) + Image(systemName: "heart.fill") + .font(.system(size: 50)) + .foregroundStyle( + LinearGradient( + colors: [.pink, .orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + } + + private var textBlock: some View { + VStack(spacing: 10) { + Text(L("nudge.title")) + .font(.title2.bold()) + .multilineTextAlignment(.center) + Text(L("nudge.subtitle")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(2) + } + } + + @ViewBuilder + private var productList: some View { + switch service.loadState { + case .loading: + HStack { + Text(L("settings.donate.loading")) + .foregroundStyle(.secondary) + .font(.subheadline) + Spacer() + ProgressView().controlSize(.small) + } + .padding() + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) + + case .loaded(let products): + VStack(spacing: 8) { + ForEach(products, id: \.id) { product in + NudgeProductRow(product: product, service: service) + } + } + + case .empty, .failed: + EmptyView() + } + } +} + +// MARK: - NudgeProductRow + +private struct NudgeProductRow: View { + let product: Product + let service: DonationService + + private var isPurchasing: Bool { service.purchaseState.activePurchasingID == product.id } + private var isThankYou: Bool { service.purchaseState.thankYouID == product.id } + private var isDisabled: Bool { service.purchaseState.activePurchasingID != nil } + + var body: some View { + Button { + guard !isDisabled else { return } + Task { await service.purchase(product) } + } label: { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(product.displayName) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + Text(product.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + trailingView + } + .padding(14) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .disabled(isDisabled) + } + + @ViewBuilder + private var trailingView: some View { + if isPurchasing { + ProgressView().controlSize(.small) + } else if isThankYou { + Image(systemName: "heart.fill") + .foregroundStyle(.pink) + } else { + Text(product.displayPrice) + .font(.subheadline.bold()) + .monospacedDigit() + .foregroundStyle( + LinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing) + ) + } + } +} + +// MARK: - SupporterBadge + +/// Inline badge shown in Settings for users who have donated. +struct SupporterBadgeRow: View { + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.pink, .orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 42, height: 42) + Image(systemName: "heart.fill") + .font(.system(size: 19)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text(L("supporter.badge.title")) + .font(.body.bold()) + .foregroundStyle(.primary) + Text(L("supporter.badge.subtitle")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } +} diff --git a/bookstax/Views/Shared/HTMLWebView.swift b/bookstax/Views/Shared/HTMLWebView.swift new file mode 100644 index 0000000..2d5248a --- /dev/null +++ b/bookstax/Views/Shared/HTMLWebView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import WebKit + +/// A SwiftUI wrapper for WKWebView that loads HTML content. +/// Replaces the iOS 26-only WebPage / WebView combo. +struct HTMLWebView: UIViewRepresentable { + let html: String + let baseURL: URL? + /// When true, tapped links open in the default browser instead of navigating in-place. + var openLinksExternally: Bool = true + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.scrollView.bounces = true + webView.isOpaque = false + webView.backgroundColor = .clear + webView.navigationDelegate = context.coordinator + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + let coordinator = context.coordinator + coordinator.openLinksExternally = openLinksExternally + // Only reload when the HTML has actually changed to avoid flicker. + guard coordinator.lastHTML != html else { return } + coordinator.lastHTML = html + webView.loadHTMLString(html, baseURL: baseURL) + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, WKNavigationDelegate { + var lastHTML: String = "" + var openLinksExternally: Bool = true + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if openLinksExternally, + navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url { + UIApplication.shared.open(url) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } + } +} diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings index 825cf17..e514ac8 100644 --- a/bookstax/de.lproj/Localizable.strings +++ b/bookstax/de.lproj/Localizable.strings @@ -219,6 +219,12 @@ "settings.reader" = "Leser"; "settings.reader.showcomments" = "Kommentare anzeigen"; +// MARK: - Data +"settings.data" = "Daten"; +"settings.data.clearcache" = "Cache leeren"; +"settings.data.clearcache.footer" = "Löscht zwischengespeicherte Server-Antworten. Hilfreich, wenn Titel oder Inhalte nach einem Update noch veraltet angezeigt werden."; +"settings.data.clearcache.done" = "Cache geleert"; + // MARK: - Logging "settings.log" = "Protokoll"; "settings.log.enabled" = "Protokollierung aktivieren"; @@ -274,6 +280,15 @@ "settings.donate.donated.on" = "Gespendet am %@"; "settings.donate.pending" = "Ausstehende Bestätigung…"; +// MARK: - Support Nudge +"nudge.title" = "Hilf mit, BookStax am Laufen zu halten"; +"nudge.subtitle" = "BookStax ist ein Herzensprojekt — kostenlos, offen und werbefrei. Deine Unterstützung hilft, die App aktiv, modern und wachsend zu gestalten."; +"nudge.dismiss" = "Vielleicht später"; + +// MARK: - Supporter Badge +"supporter.badge.title" = "BookStax Supporter"; +"supporter.badge.subtitle" = "Danke, dass du die Entwicklung unterstützt!"; + // MARK: - Common "common.ok" = "OK"; "common.cancel" = "Abbrechen"; diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings index 5d2942a..e99d756 100644 --- a/bookstax/en.lproj/Localizable.strings +++ b/bookstax/en.lproj/Localizable.strings @@ -219,6 +219,12 @@ "settings.reader" = "Reader"; "settings.reader.showcomments" = "Show Comments"; +// MARK: - Data +"settings.data" = "Data"; +"settings.data.clearcache" = "Clear Cache"; +"settings.data.clearcache.footer" = "Clears cached server responses. Useful if titles or content appear outdated after an update."; +"settings.data.clearcache.done" = "Cache Cleared"; + // MARK: - Logging "settings.log" = "Logging"; "settings.log.enabled" = "Enable Logging"; @@ -274,6 +280,15 @@ "settings.donate.donated.on" = "Donated on %@"; "settings.donate.pending" = "Pending confirmation…"; +// MARK: - Support Nudge +"nudge.title" = "Help keep BookStax going"; +"nudge.subtitle" = "BookStax is a passion project — free, open, and ad-free. Your support helps keep it maintained, modern, and growing."; +"nudge.dismiss" = "Maybe later"; + +// MARK: - Supporter Badge +"supporter.badge.title" = "BookStax Supporter"; +"supporter.badge.subtitle" = "Thank you for supporting the development!"; + // MARK: - Common "common.ok" = "OK"; "common.cancel" = "Cancel"; diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings index 644a66e..598ff3c 100644 --- a/bookstax/es.lproj/Localizable.strings +++ b/bookstax/es.lproj/Localizable.strings @@ -219,6 +219,12 @@ "settings.reader" = "Lector"; "settings.reader.showcomments" = "Mostrar comentarios"; +// MARK: - Data +"settings.data" = "Datos"; +"settings.data.clearcache" = "Vaciar caché"; +"settings.data.clearcache.footer" = "Borra las respuestas del servidor almacenadas en caché. Útil si los títulos o el contenido aparecen desactualizados tras una actualización."; +"settings.data.clearcache.done" = "Caché vaciada"; + // MARK: - Logging "settings.log" = "Registro"; "settings.log.enabled" = "Activar registro"; @@ -274,6 +280,15 @@ "settings.donate.donated.on" = "Donado el %@"; "settings.donate.pending" = "Confirmación pendiente…"; +// MARK: - Support Nudge +"nudge.title" = "Ayuda a mantener BookStax"; +"nudge.subtitle" = "BookStax es un proyecto personal — gratuito, abierto y sin anuncios. Tu apoyo ayuda a mantenerlo activo, moderno y en crecimiento."; +"nudge.dismiss" = "Quizás más tarde"; + +// MARK: - Supporter Badge +"supporter.badge.title" = "Supporter de BookStax"; +"supporter.badge.subtitle" = "¡Gracias por apoyar el desarrollo!"; + // MARK: - Common "common.ok" = "Aceptar"; "common.cancel" = "Cancelar"; diff --git a/bookstax/fr b/bookstax/fr new file mode 100644 index 0000000..196225b --- /dev/null +++ b/bookstax/fr @@ -0,0 +1,297 @@ +// MARK: - Onboarding +"onboarding.language.title" = "Choisissez votre langue"; +"onboarding.language.subtitle" = "Vous pouvez le modifier plus tard dans les réglages."; +"onboarding.welcome.title" = "Bienvenue sur BookStax"; +"onboarding.welcome.subtitle" = "Votre base de connaissances auto-hébergée,\ndans votre poche."; +"onboarding.welcome.cta" = "Commencer"; +"onboarding.server.title" = "Où se trouve votre BookStack ?"; +"onboarding.server.subtitle" = "Saisissez l'adresse web de votre installation BookStack. C'est la même URL que vous utilisez dans votre navigateur."; +"onboarding.server.placeholder" = "https://wiki.monentreprise.com"; +"onboarding.server.next" = "Suivant"; +"onboarding.server.error.empty" = "Veuillez saisir l'adresse de votre serveur BookStack."; +"onboarding.server.error.invalid" = "Cette adresse ne semble pas valide. Essayez quelque chose comme https://bookstack.exemple.com"; +"onboarding.server.warning.http" = "Connexion non chiffrée détectée. Vos données peuvent être visibles sur le réseau."; +"onboarding.server.warning.remote" = "Cette adresse semble être publique. Exposer BookStack sur Internet est un risque de sécurité — pensez à utiliser un VPN ou à le garder sur votre réseau local."; +"onboarding.token.title" = "Connexion avec un jeton d'API"; +"onboarding.token.subtitle" = "BookStack utilise des jetons d'API pour un accès sécurisé. Vous devez en créer un dans votre profil BookStack."; +"onboarding.token.help" = "Comment obtenir un jeton ?"; +"onboarding.token.help.steps" = "1. Ouvrez votre instance BookStack dans un navigateur\n2. Cliquez sur votre avatar → Modifier le profil\n3. Faites défiler jusqu'à \"Jetons API\" → appuyez sur \"Créer un jeton\"\n4. Définissez un nom (ex. \"Mon iPhone\") et une date d'expiration\n5. Copiez l'ID et le secret du jeton — ils ne seront plus affichés\n\nRemarque : votre compte doit avoir la permission \"Accéder à l'API système\". Contactez votre administrateur si vous ne voyez pas la section Jetons API."; +"onboarding.token.id.label" = "ID du jeton"; +"onboarding.token.secret.label" = "Secret du jeton"; +"onboarding.token.paste" = "Coller depuis le presse-papiers"; +"onboarding.token.verify" = "Vérifier la connexion"; +"onboarding.verify.ready" = "Prêt à vérifier"; +"onboarding.verify.reaching" = "Connexion au serveur…"; +"onboarding.verify.found" = "%@ trouvé"; +"onboarding.verify.checking" = "Vérification des identifiants…"; +"onboarding.verify.connected" = "Connecté à %@"; +"onboarding.verify.server.failed" = "Serveur inaccessible"; +"onboarding.verify.token.failed" = "Échec de l'authentification"; +"onboarding.verify.phase.server" = "Connexion au serveur"; +"onboarding.verify.phase.token" = "Vérification du jeton"; +"onboarding.verify.goback" = "Retour"; +"onboarding.verify.retry" = "Réessayer"; +"onboarding.ready.title" = "Tout est prêt !"; +"onboarding.ready.subtitle" = "BookStax est connecté à votre base de connaissances."; +"onboarding.ready.cta" = "Ouvrir ma bibliothèque"; +"onboarding.ready.feature.library" = "Parcourir la bibliothèque"; +"onboarding.ready.feature.library.desc" = "Naviguez dans les étagères, livres, chapitres et pages"; +"onboarding.ready.feature.search" = "Tout rechercher"; +"onboarding.ready.feature.search.desc" = "Trouvez n'importe quel contenu instantanément"; +"onboarding.ready.feature.create" = "Créer et modifier"; +"onboarding.ready.feature.create.desc" = "Rédigez de nouvelles pages en Markdown"; + +// MARK: - Tabs +"tab.quicknote" = "Note rapide"; +"tab.library" = "Bibliothèque"; +"tab.search" = "Recherche"; +"tab.create" = "Créer"; +"tab.settings" = "Réglages"; + +// MARK: - Quick Note +"quicknote.title" = "Note rapide"; +"quicknote.field.title" = "Titre"; +"quicknote.field.title.placeholder" = "Titre de la note"; +"quicknote.field.content" = "Contenu"; +"quicknote.section.location" = "Emplacement"; +"quicknote.section.tags" = "Étiquettes"; +"quicknote.shelf.label" = "Étagère"; +"quicknote.shelf.none" = "Toute étagère"; +"quicknote.shelf.loading" = "Chargement des étagères…"; +"quicknote.book.label" = "Livre"; +"quicknote.book.none" = "Sélectionner un livre"; +"quicknote.book.loading" = "Chargement des livres…"; +"quicknote.tags.loading" = "Chargement des étiquettes…"; +"quicknote.tags.add" = "Ajouter des étiquettes"; +"quicknote.tags.edit" = "Modifier les étiquettes"; +"quicknote.tags.empty" = "Aucune étiquette disponible sur ce serveur."; +"quicknote.tags.picker.title" = "Sélectionner des étiquettes"; +"quicknote.save" = "Enregistrer"; +"quicknote.error.nobook" = "Veuillez d'abord sélectionner un livre."; +"quicknote.saved.online" = "Note enregistrée comme nouvelle page."; +"quicknote.saved.offline" = "Enregistré localement — sera envoyé dès que vous serez en ligne."; +"quicknote.pending.title" = "Notes hors ligne"; +"quicknote.pending.upload" = "Envoyer maintenant"; +"quicknote.pending.uploading" = "Envoi en cours…"; + +// MARK: - Library +"library.title" = "Bibliothèque"; +"library.loading" = "Chargement de la bibliothèque…"; +"library.empty.title" = "Aucune étagère"; +"library.empty.message" = "Votre bibliothèque est vide. Créez une étagère dans BookStack pour commencer."; +"library.refresh" = "Actualiser"; +"library.shelves" = "Étagères"; +"library.updated" = "Mis à jour %@"; +"library.newshelf" = "Nouvelle étagère"; + +// MARK: - Shelf +"shelf.loading" = "Chargement des livres…"; +"shelf.empty.title" = "Aucun livre"; +"shelf.empty.message" = "Cette étagère ne contient pas encore de livres."; +"shelf.newbook" = "Nouveau livre"; + +// MARK: - Book +"book.loading" = "Chargement du contenu…"; +"book.empty.title" = "Aucun contenu"; +"book.empty.message" = "Ce livre ne contient pas encore de chapitres ni de pages."; +"book.addpage" = "Ajouter une page"; +"book.newpage" = "Nouvelle page"; +"book.newchapter" = "Nouveau chapitre"; +"book.pages" = "Pages"; +"book.delete" = "Supprimer"; +"book.open" = "Ouvrir"; +"book.sharelink" = "Partager le lien"; +"book.addcontent" = "Ajouter du contenu"; + +// MARK: - Chapter +"chapter.new.title" = "Nouveau chapitre"; +"chapter.new.name" = "Nom du chapitre"; +"chapter.new.description" = "Description (facultatif)"; +"chapter.details" = "Détails du chapitre"; +"chapter.cancel" = "Annuler"; +"chapter.create" = "Créer"; + +// MARK: - Page Reader +"reader.comments" = "Commentaires (%d)"; +"reader.comments.empty" = "Aucun commentaire pour l'instant. Soyez le premier !"; +"reader.comment.placeholder" = "Ajouter un commentaire…"; +"reader.comment.post" = "Publier le commentaire"; +"reader.edit" = "Modifier la page"; +"reader.share" = "Partager la page"; +"reader.nocontent" = "Aucun contenu"; + +// MARK: - Editor +"editor.new.title" = "Nouvelle page"; +"editor.edit.title" = "Modifier la page"; +"editor.title.placeholder" = "Titre de la page"; +"editor.tab.write" = "Écrire"; +"editor.tab.preview" = "Aperçu"; +"editor.save" = "Enregistrer"; +"editor.close" = "Fermer"; +"editor.discard.keepediting" = "Continuer à modifier"; +"editor.close.unsaved.title" = "Fermer sans enregistrer ?"; +"editor.close.unsaved.confirm" = "Fermer"; +"editor.image.uploading" = "Chargement de l'image…"; +"editor.html.notice" = "Cette page utilise le format HTML. La modifier ici la convertira en Markdown."; + +// MARK: - Search +"search.title" = "Recherche"; +"search.prompt" = "Rechercher des livres, pages, chapitres…"; +"search.loading" = "Recherche en cours…"; +"search.empty.title" = "Rechercher dans BookStack"; +"search.empty.message" = "Recherchez des pages, livres, chapitres et étagères dans toute votre base de connaissances."; +"search.recent" = "Recherches récentes"; +"search.recent.clear" = "Effacer"; +"search.filter" = "Filtrer les résultats"; +"search.filter.all" = "Tous"; +"search.opening" = "Ouverture…"; +"search.error.title" = "Impossible d'ouvrir le résultat"; +"search.type.page" = "Pages"; +"search.type.book" = "Livres"; +"search.type.chapter" = "Chapitres"; +"search.type.shelf" = "Étagères"; + +// MARK: - New Content +"create.title" = "Créer"; +"create.section" = "Que souhaitez-vous créer ?"; +"create.page.title" = "Nouvelle page"; +"create.page.desc" = "Rédigez une nouvelle page en Markdown"; +"create.book.title" = "Nouveau livre"; +"create.book.desc" = "Organisez des pages dans un livre"; +"create.shelf.title" = "Nouvelle étagère"; +"create.shelf.desc" = "Regroupez des livres dans une étagère"; +"create.book.name" = "Nom du livre"; +"create.book.details" = "Détails du livre"; +"create.book.shelf.header" = "Étagère (facultatif)"; +"create.book.shelf.footer" = "Assignez ce livre à une étagère pour mieux organiser votre bibliothèque."; +"create.book.shelf.none" = "Aucune"; +"create.book.shelf.loading" = "Chargement des étagères…"; +"create.shelf.name" = "Nom de l'étagère"; +"create.shelf.details" = "Détails de l'étagère"; +"create.page.filter.shelf" = "Filtrer par étagère (facultatif)"; +"create.page.book.header" = "Livre"; +"create.page.book.footer" = "La page sera créée dans ce livre."; +"create.page.book.select" = "Sélectionner un livre…"; +"create.page.nobooks" = "Aucun livre disponible"; +"create.page.nobooks.shelf" = "Aucun livre dans cette étagère"; +"create.page.loading" = "Chargement…"; +"create.page.next" = "Suivant"; +"create.description" = "Description (facultatif)"; +"create.cancel" = "Annuler"; +"create.create" = "Créer"; +"create.loading.books" = "Chargement des livres…"; +"create.any.shelf" = "Toute étagère"; + +// MARK: - Settings +"settings.title" = "Réglages"; +"settings.account" = "Compte"; +"settings.account.connected" = "Connecté"; +"settings.account.copyurl" = "Copier l'URL du serveur"; +"settings.account.signout" = "Se déconnecter"; +"settings.signout.alert.title" = "Se déconnecter"; +"settings.signout.alert.message" = "Cela supprimera vos identifiants enregistrés et vous devrez vous reconnecter."; +"settings.signout.alert.confirm" = "Se déconnecter"; +"settings.signout.alert.cancel" = "Annuler"; +"settings.sync" = "Synchronisation"; +"settings.sync.wifionly" = "Synchroniser en Wi-Fi uniquement"; +"settings.sync.now" = "Synchroniser maintenant"; +"settings.sync.lastsynced" = "Dernière synchronisation"; +"settings.about" = "À propos"; +"settings.about.version" = "Version"; +"settings.about.docs" = "Documentation BookStack"; +"settings.about.issue" = "Signaler un problème"; +"settings.about.credit" = "BookStack est un logiciel open source créé par Dan Brown."; +"settings.language" = "Langue"; +"settings.language.header" = "Langue"; + +// MARK: - Offline +"offline.banner" = "Vous êtes hors ligne — affichage du contenu mis en cache"; + +// MARK: - Appearance +"settings.appearance" = "Apparence"; +"settings.appearance.theme" = "Thème"; +"settings.appearance.theme.system" = "Système"; +"settings.appearance.theme.light" = "Clair"; +"settings.appearance.theme.dark" = "Sombre"; +"settings.appearance.accent" = "Couleur d'accentuation"; + +// MARK: - Reader Settings +"settings.reader" = "Lecteur"; +"settings.reader.showcomments" = "Afficher les commentaires"; + +// MARK: - Data +"settings.data" = "Données"; +"settings.data.clearcache" = "Vider le cache"; +"settings.data.clearcache.footer" = "Efface les réponses serveur mises en cache. Utile si les titres ou le contenu semblent obsolètes après une mise à jour."; +"settings.data.clearcache.done" = "Cache vidé"; + +// MARK: - Logging +"settings.log" = "Journalisation"; +"settings.log.enabled" = "Activer la journalisation"; +"settings.log.share" = "Partager le journal"; +"settings.log.clear" = "Effacer le journal"; +"settings.log.viewer.title" = "Journal de l'app"; +"settings.log.entries" = "%d entrées"; + +// MARK: - Tags +"editor.tags.title" = "Étiquettes"; +"editor.tags.add" = "Ajouter une étiquette"; +"editor.tags.create" = "Créer une nouvelle étiquette"; +"editor.tags.name" = "Nom de l'étiquette"; +"editor.tags.value" = "Valeur (facultatif)"; +"editor.tags.current" = "Étiquettes assignées"; +"editor.tags.available" = "Étiquettes disponibles"; +"editor.tags.loading" = "Chargement des étiquettes…"; +"editor.tags.new" = "Créer une étiquette"; +"editor.tags.search" = "Rechercher des étiquettes…"; +"editor.tags.suggestions" = "Suggestions"; +"search.filter.type" = "Type de contenu"; +"search.filter.tag" = "Étiquette"; +"search.filter.tag.clear" = "Effacer le filtre d'étiquette"; + +// MARK: - Servers +"settings.servers" = "Serveurs"; +"settings.servers.add" = "Ajouter un serveur…"; +"settings.servers.active" = "Actif"; +"settings.servers.switch.title" = "Changer de serveur"; +"settings.servers.switch.message" = "Passer à \"%@\" ? L'application va se recharger."; +"settings.servers.switch.confirm" = "Changer"; +"settings.servers.delete.title" = "Supprimer le serveur"; +"settings.servers.delete.message" = "Supprimer \"%@\" ? Son contenu mis en cache sera effacé. Cette action est irréversible."; +"settings.servers.delete.confirm" = "Supprimer"; +"settings.servers.delete.active.title" = "Supprimer le serveur actif ?"; +"settings.servers.delete.active.message" = "\"%@\" est votre serveur actuel. Le supprimer effacera tout le contenu mis en cache et vous déconnectera de ce serveur."; +"settings.servers.edit" = "Modifier"; +"settings.servers.edit.title" = "Modifier le serveur"; +"settings.servers.edit.changecreds" = "Mettre à jour le jeton d'API"; +"settings.servers.edit.changecreds.footer" = "Activez cette option pour remplacer l'ID et le secret du jeton enregistrés pour ce serveur."; +"onboarding.server.name.label" = "Nom du serveur"; +"onboarding.server.name.placeholder" = "ex. Wiki professionnel"; + +// MARK: - Donations +"settings.donate" = "Soutenir BookStax"; +"settings.donate.page" = "Page"; +"settings.donate.book" = "Livre"; +"settings.donate.encyclopedia" = "Encyclopédie"; +"settings.donate.footer" = "Vous appréciez BookStax ? Votre soutien contribue à maintenir l'app gratuite et en développement actif. Merci !"; +"settings.donate.loading" = "Chargement…"; +"settings.donate.error" = "Impossible de charger les options de don."; +"settings.donate.empty" = "Aucune option de don disponible."; +"settings.donate.donated.on" = "Don effectué le %@"; +"settings.donate.pending" = "En attente de confirmation…"; + +// MARK: - Support Nudge +"nudge.title" = "Contribuez à faire vivre BookStax"; +"nudge.subtitle" = "BookStax est un projet passionnel — gratuit, ouvert et sans publicité. Votre soutien aide à le maintenir moderne et en développement actif."; +"nudge.dismiss" = "Peut-être plus tard"; + +// MARK: - Supporter Badge +"supporter.badge.title" = "Supporter BookStax"; +"supporter.badge.subtitle" = "Merci de soutenir le développement !"; + +// MARK: - Common +"common.ok" = "OK"; +"common.cancel" = "Annuler"; +"common.retry" = "Réessayer"; +"common.error" = "Erreur inconnue"; +"common.done" = "Terminé"; diff --git a/bookstax/fr.lproj/Localizable.strings b/bookstax/fr.lproj/Localizable.strings new file mode 100644 index 0000000..61de6a5 --- /dev/null +++ b/bookstax/fr.lproj/Localizable.strings @@ -0,0 +1,297 @@ +// MARK: - Onboarding +"onboarding.language.title" = "Choisissez votre langue"; +"onboarding.language.subtitle" = "Vous pouvez le modifier plus tard dans les réglages."; +"onboarding.welcome.title" = "Bienvenue sur BookStax"; +"onboarding.welcome.subtitle" = "Votre base de connaissances auto-hébergée,\ndans votre poche."; +"onboarding.welcome.cta" = "Commencer"; +"onboarding.server.title" = "Où se trouve votre BookStack ?"; +"onboarding.server.subtitle" = "Saisissez l'adresse web de votre installation BookStack. C'est la même URL que vous utilisez dans votre navigateur."; +"onboarding.server.placeholder" = "https://wiki.monentreprise.com"; +"onboarding.server.next" = "Suivant"; +"onboarding.server.error.empty" = "Veuillez saisir l'adresse de votre serveur BookStack."; +"onboarding.server.error.invalid" = "Cette adresse ne semble pas valide. Essayez quelque chose comme https://bookstack.exemple.com"; +"onboarding.server.warning.http" = "Connexion non chiffrée détectée. Vos données peuvent être visibles sur le réseau."; +"onboarding.server.warning.remote" = "Cette adresse semble être publique. Exposer BookStack sur Internet est un risque de sécurité — pensez à utiliser un VPN ou à le garder sur votre réseau local."; +"onboarding.token.title" = "Connexion avec un jeton d'API"; +"onboarding.token.subtitle" = "BookStack utilise des jetons d'API pour un accès sécurisé. Vous devez en créer un dans votre profil BookStack."; +"onboarding.token.help" = "Comment obtenir un jeton ?"; +"onboarding.token.help.steps" = "1. Ouvrez votre instance BookStack dans un navigateur\n2. Cliquez sur votre avatar → Modifier le profil\n3. Faites défiler jusqu'à \"Jetons API\" → appuyez sur \"Créer un jeton\"\n4. Définissez un nom (ex. \"Mon iPhone\") et une date d'expiration\n5. Copiez l'ID et le secret du jeton — ils ne seront plus affichés\n\nRemarque : votre compte doit avoir la permission \"Accéder à l'API système\". Contactez votre administrateur si vous ne voyez pas la section Jetons API."; +"onboarding.token.id.label" = "ID du jeton"; +"onboarding.token.secret.label" = "Secret du jeton"; +"onboarding.token.paste" = "Coller depuis le presse-papiers"; +"onboarding.token.verify" = "Vérifier la connexion"; +"onboarding.verify.ready" = "Prêt à vérifier"; +"onboarding.verify.reaching" = "Connexion au serveur\u{2026}"; +"onboarding.verify.found" = "%@ trouvé"; +"onboarding.verify.checking" = "Vérification des identifiants\u{2026}"; +"onboarding.verify.connected" = "Connecté à %@"; +"onboarding.verify.server.failed" = "Serveur inaccessible"; +"onboarding.verify.token.failed" = "Échec de l'authentification"; +"onboarding.verify.phase.server" = "Connexion au serveur"; +"onboarding.verify.phase.token" = "Vérification du jeton"; +"onboarding.verify.goback" = "Retour"; +"onboarding.verify.retry" = "Réessayer"; +"onboarding.ready.title" = "Tout est prêt !"; +"onboarding.ready.subtitle" = "BookStax est connecté à votre base de connaissances."; +"onboarding.ready.cta" = "Ouvrir ma bibliothèque"; +"onboarding.ready.feature.library" = "Parcourir la bibliothèque"; +"onboarding.ready.feature.library.desc" = "Naviguez dans les étagères, livres, chapitres et pages"; +"onboarding.ready.feature.search" = "Tout rechercher"; +"onboarding.ready.feature.search.desc" = "Trouvez n'importe quel contenu instantanément"; +"onboarding.ready.feature.create" = "Créer et modifier"; +"onboarding.ready.feature.create.desc" = "Rédigez de nouvelles pages en Markdown"; + +// MARK: - Tabs +"tab.quicknote" = "Note rapide"; +"tab.library" = "Bibliothèque"; +"tab.search" = "Recherche"; +"tab.create" = "Créer"; +"tab.settings" = "Réglages"; + +// MARK: - Quick Note +"quicknote.title" = "Note rapide"; +"quicknote.field.title" = "Titre"; +"quicknote.field.title.placeholder" = "Titre de la note"; +"quicknote.field.content" = "Contenu"; +"quicknote.section.location" = "Emplacement"; +"quicknote.section.tags" = "Étiquettes"; +"quicknote.shelf.label" = "Étagère"; +"quicknote.shelf.none" = "Toute étagère"; +"quicknote.shelf.loading" = "Chargement des étagères\u{2026}"; +"quicknote.book.label" = "Livre"; +"quicknote.book.none" = "Sélectionner un livre"; +"quicknote.book.loading" = "Chargement des livres\u{2026}"; +"quicknote.tags.loading" = "Chargement des étiquettes\u{2026}"; +"quicknote.tags.add" = "Ajouter des étiquettes"; +"quicknote.tags.edit" = "Modifier les étiquettes"; +"quicknote.tags.empty" = "Aucune étiquette disponible sur ce serveur."; +"quicknote.tags.picker.title" = "Sélectionner des étiquettes"; +"quicknote.save" = "Enregistrer"; +"quicknote.error.nobook" = "Veuillez d'abord sélectionner un livre."; +"quicknote.saved.online" = "Note enregistrée comme nouvelle page."; +"quicknote.saved.offline" = "Enregistré localement — sera envoyé dès que vous serez en ligne."; +"quicknote.pending.title" = "Notes hors ligne"; +"quicknote.pending.upload" = "Envoyer maintenant"; +"quicknote.pending.uploading" = "Envoi en cours\u{2026}"; + +// MARK: - Library +"library.title" = "Bibliothèque"; +"library.loading" = "Chargement de la bibliothèque\u{2026}"; +"library.empty.title" = "Aucune étagère"; +"library.empty.message" = "Votre bibliothèque est vide. Créez une étagère dans BookStack pour commencer."; +"library.refresh" = "Actualiser"; +"library.shelves" = "Étagères"; +"library.updated" = "Mis à jour %@"; +"library.newshelf" = "Nouvelle étagère"; + +// MARK: - Shelf +"shelf.loading" = "Chargement des livres\u{2026}"; +"shelf.empty.title" = "Aucun livre"; +"shelf.empty.message" = "Cette étagère ne contient pas encore de livres."; +"shelf.newbook" = "Nouveau livre"; + +// MARK: - Book +"book.loading" = "Chargement du contenu\u{2026}"; +"book.empty.title" = "Aucun contenu"; +"book.empty.message" = "Ce livre ne contient pas encore de chapitres ni de pages."; +"book.addpage" = "Ajouter une page"; +"book.newpage" = "Nouvelle page"; +"book.newchapter" = "Nouveau chapitre"; +"book.pages" = "Pages"; +"book.delete" = "Supprimer"; +"book.open" = "Ouvrir"; +"book.sharelink" = "Partager le lien"; +"book.addcontent" = "Ajouter du contenu"; + +// MARK: - Chapter +"chapter.new.title" = "Nouveau chapitre"; +"chapter.new.name" = "Nom du chapitre"; +"chapter.new.description" = "Description (facultatif)"; +"chapter.details" = "Détails du chapitre"; +"chapter.cancel" = "Annuler"; +"chapter.create" = "Créer"; + +// MARK: - Page Reader +"reader.comments" = "Commentaires (%d)"; +"reader.comments.empty" = "Aucun commentaire pour l'instant. Soyez le premier !"; +"reader.comment.placeholder" = "Ajouter un commentaire\u{2026}"; +"reader.comment.post" = "Publier le commentaire"; +"reader.edit" = "Modifier la page"; +"reader.share" = "Partager la page"; +"reader.nocontent" = "Aucun contenu"; + +// MARK: - Editor +"editor.new.title" = "Nouvelle page"; +"editor.edit.title" = "Modifier la page"; +"editor.title.placeholder" = "Titre de la page"; +"editor.tab.write" = "Écrire"; +"editor.tab.preview" = "Aperçu"; +"editor.save" = "Enregistrer"; +"editor.close" = "Fermer"; +"editor.discard.keepediting" = "Continuer à modifier"; +"editor.close.unsaved.title" = "Fermer sans enregistrer ?"; +"editor.close.unsaved.confirm" = "Fermer"; +"editor.image.uploading" = "Chargement de l'image\u{2026}"; +"editor.html.notice" = "Cette page utilise le format HTML. La modifier ici la convertira en Markdown."; + +// MARK: - Search +"search.title" = "Recherche"; +"search.prompt" = "Rechercher des livres, pages, chapitres\u{2026}"; +"search.loading" = "Recherche en cours\u{2026}"; +"search.empty.title" = "Rechercher dans BookStack"; +"search.empty.message" = "Recherchez des pages, livres, chapitres et étagères dans toute votre base de connaissances."; +"search.recent" = "Recherches récentes"; +"search.recent.clear" = "Effacer"; +"search.filter" = "Filtrer les résultats"; +"search.filter.all" = "Tous"; +"search.opening" = "Ouverture\u{2026}"; +"search.error.title" = "Impossible d'ouvrir le résultat"; +"search.type.page" = "Pages"; +"search.type.book" = "Livres"; +"search.type.chapter" = "Chapitres"; +"search.type.shelf" = "Étagères"; + +// MARK: - New Content +"create.title" = "Créer"; +"create.section" = "Que souhaitez-vous créer ?"; +"create.page.title" = "Nouvelle page"; +"create.page.desc" = "Rédigez une nouvelle page en Markdown"; +"create.book.title" = "Nouveau livre"; +"create.book.desc" = "Organisez des pages dans un livre"; +"create.shelf.title" = "Nouvelle étagère"; +"create.shelf.desc" = "Regroupez des livres dans une étagère"; +"create.book.name" = "Nom du livre"; +"create.book.details" = "Détails du livre"; +"create.book.shelf.header" = "Étagère (facultatif)"; +"create.book.shelf.footer" = "Assignez ce livre à une étagère pour mieux organiser votre bibliothèque."; +"create.book.shelf.none" = "Aucune"; +"create.book.shelf.loading" = "Chargement des étagères\u{2026}"; +"create.shelf.name" = "Nom de l'étagère"; +"create.shelf.details" = "Détails de l'étagère"; +"create.page.filter.shelf" = "Filtrer par étagère (facultatif)"; +"create.page.book.header" = "Livre"; +"create.page.book.footer" = "La page sera créée dans ce livre."; +"create.page.book.select" = "Sélectionner un livre\u{2026}"; +"create.page.nobooks" = "Aucun livre disponible"; +"create.page.nobooks.shelf" = "Aucun livre dans cette étagère"; +"create.page.loading" = "Chargement\u{2026}"; +"create.page.next" = "Suivant"; +"create.description" = "Description (facultatif)"; +"create.cancel" = "Annuler"; +"create.create" = "Créer"; +"create.loading.books" = "Chargement des livres\u{2026}"; +"create.any.shelf" = "Toute étagère"; + +// MARK: - Settings +"settings.title" = "Réglages"; +"settings.account" = "Compte"; +"settings.account.connected" = "Connecté"; +"settings.account.copyurl" = "Copier l'URL du serveur"; +"settings.account.signout" = "Se déconnecter"; +"settings.signout.alert.title" = "Se déconnecter"; +"settings.signout.alert.message" = "Cela supprimera vos identifiants enregistrés et vous devrez vous reconnecter."; +"settings.signout.alert.confirm" = "Se déconnecter"; +"settings.signout.alert.cancel" = "Annuler"; +"settings.sync" = "Synchronisation"; +"settings.sync.wifionly" = "Synchroniser en Wi-Fi uniquement"; +"settings.sync.now" = "Synchroniser maintenant"; +"settings.sync.lastsynced" = "Dernière synchronisation"; +"settings.about" = "À propos"; +"settings.about.version" = "Version"; +"settings.about.docs" = "Documentation BookStack"; +"settings.about.issue" = "Signaler un problème"; +"settings.about.credit" = "BookStack est un logiciel open source créé par Dan Brown."; +"settings.language" = "Langue"; +"settings.language.header" = "Langue"; + +// MARK: - Offline +"offline.banner" = "Vous êtes hors ligne — affichage du contenu mis en cache"; + +// MARK: - Appearance +"settings.appearance" = "Apparence"; +"settings.appearance.theme" = "Thème"; +"settings.appearance.theme.system" = "Système"; +"settings.appearance.theme.light" = "Clair"; +"settings.appearance.theme.dark" = "Sombre"; +"settings.appearance.accent" = "Couleur d'accentuation"; + +// MARK: - Reader Settings +"settings.reader" = "Lecteur"; +"settings.reader.showcomments" = "Afficher les commentaires"; + +// MARK: - Data +"settings.data" = "Données"; +"settings.data.clearcache" = "Vider le cache"; +"settings.data.clearcache.footer" = "Efface les réponses serveur mises en cache. Utile si les titres ou le contenu semblent obsolètes après une mise à jour."; +"settings.data.clearcache.done" = "Cache vidé"; + +// MARK: - Logging +"settings.log" = "Journalisation"; +"settings.log.enabled" = "Activer la journalisation"; +"settings.log.share" = "Partager le journal"; +"settings.log.clear" = "Effacer le journal"; +"settings.log.viewer.title" = "Journal de l'app"; +"settings.log.entries" = "%d entrées"; + +// MARK: - Tags +"editor.tags.title" = "Étiquettes"; +"editor.tags.add" = "Ajouter une étiquette"; +"editor.tags.create" = "Créer une nouvelle étiquette"; +"editor.tags.name" = "Nom de l'étiquette"; +"editor.tags.value" = "Valeur (facultatif)"; +"editor.tags.current" = "Étiquettes assignées"; +"editor.tags.available" = "Étiquettes disponibles"; +"editor.tags.loading" = "Chargement des étiquettes\u{2026}"; +"editor.tags.new" = "Créer une étiquette"; +"editor.tags.search" = "Rechercher des étiquettes\u{2026}"; +"editor.tags.suggestions" = "Suggestions"; +"search.filter.type" = "Type de contenu"; +"search.filter.tag" = "Étiquette"; +"search.filter.tag.clear" = "Effacer le filtre d'étiquette"; + +// MARK: - Servers +"settings.servers" = "Serveurs"; +"settings.servers.add" = "Ajouter un serveur\u{2026}"; +"settings.servers.active" = "Actif"; +"settings.servers.switch.title" = "Changer de serveur"; +"settings.servers.switch.message" = "Passer à \"%@\" ? L'application va se recharger."; +"settings.servers.switch.confirm" = "Changer"; +"settings.servers.delete.title" = "Supprimer le serveur"; +"settings.servers.delete.message" = "Supprimer \"%@\" ? Son contenu mis en cache sera effacé. Cette action est irréversible."; +"settings.servers.delete.confirm" = "Supprimer"; +"settings.servers.delete.active.title" = "Supprimer le serveur actif ?"; +"settings.servers.delete.active.message" = "\"%@\" est votre serveur actuel. Le supprimer effacera tout le contenu mis en cache et vous déconnectera de ce serveur."; +"settings.servers.edit" = "Modifier"; +"settings.servers.edit.title" = "Modifier le serveur"; +"settings.servers.edit.changecreds" = "Mettre à jour le jeton d'API"; +"settings.servers.edit.changecreds.footer" = "Activez cette option pour remplacer l'ID et le secret du jeton enregistrés pour ce serveur."; +"onboarding.server.name.label" = "Nom du serveur"; +"onboarding.server.name.placeholder" = "ex. Wiki professionnel"; + +// MARK: - Donations +"settings.donate" = "Soutenir BookStax"; +"settings.donate.page" = "Page"; +"settings.donate.book" = "Livre"; +"settings.donate.encyclopedia" = "Encyclopédie"; +"settings.donate.footer" = "Vous appréciez BookStax ? Votre soutien contribue à maintenir l'app gratuite et en développement actif. Merci !"; +"settings.donate.loading" = "Chargement\u{2026}"; +"settings.donate.error" = "Impossible de charger les options de don."; +"settings.donate.empty" = "Aucune option de don disponible."; +"settings.donate.donated.on" = "Don effectué le %@"; +"settings.donate.pending" = "En attente de confirmation\u{2026}"; + +// MARK: - Support Nudge +"nudge.title" = "Contribuez à faire vivre BookStax"; +"nudge.subtitle" = "BookStax est un projet passionnel — gratuit, ouvert et sans publicité. Votre soutien aide à le maintenir moderne et en développement actif."; +"nudge.dismiss" = "Peut-être plus tard"; + +// MARK: - Supporter Badge +"supporter.badge.title" = "Supporter BookStax"; +"supporter.badge.subtitle" = "Merci de soutenir le développement !"; + +// MARK: - Common +"common.ok" = "OK"; +"common.cancel" = "Annuler"; +"common.retry" = "Réessayer"; +"common.error" = "Erreur inconnue"; +"common.done" = "Terminé"; diff --git a/bookstaxTests/APIErrorTests.swift b/bookstaxTests/APIErrorTests.swift new file mode 100644 index 0000000..c17f6bf --- /dev/null +++ b/bookstaxTests/APIErrorTests.swift @@ -0,0 +1,129 @@ +import Testing +@testable import bookstax + +@Suite("BookStackError – errorDescription") +struct BookStackErrorTests { + + @Test("invalidURL has non-nil description mentioning https") + func invalidURL() { + let desc = BookStackError.invalidURL.errorDescription + #expect(desc != nil) + #expect(desc!.contains("https")) + } + + @Test("notAuthenticated has non-nil description") + func notAuthenticated() { + let desc = BookStackError.notAuthenticated.errorDescription + #expect(desc != nil) + #expect(!desc!.isEmpty) + } + + @Test("unauthorized mentions token") + func unauthorized() { + let desc = BookStackError.unauthorized.errorDescription + #expect(desc != nil) + #expect(desc!.lowercased().contains("token")) + } + + @Test("forbidden mentions 403 or permission") + func forbidden() { + let desc = BookStackError.forbidden.errorDescription + #expect(desc != nil) + let lower = desc!.lowercased() + #expect(lower.contains("403") || lower.contains("permission") || lower.contains("access")) + } + + @Test("notFound includes resource name in description") + func notFound() { + let desc = BookStackError.notFound(resource: "MyPage").errorDescription + #expect(desc != nil) + #expect(desc!.contains("MyPage")) + } + + @Test("httpError with message returns that message") + func httpErrorWithMessage() { + let desc = BookStackError.httpError(statusCode: 500, message: "Internal Server Error").errorDescription + #expect(desc == "Internal Server Error") + } + + @Test("httpError without message includes status code") + func httpErrorWithoutMessage() { + let desc = BookStackError.httpError(statusCode: 503, message: nil).errorDescription + #expect(desc != nil) + #expect(desc!.contains("503")) + } + + @Test("decodingError includes detail string") + func decodingError() { + let desc = BookStackError.decodingError("keyNotFound").errorDescription + #expect(desc != nil) + #expect(desc!.contains("keyNotFound")) + } + + @Test("networkUnavailable mentions cache or offline") + func networkUnavailable() { + let desc = BookStackError.networkUnavailable.errorDescription + #expect(desc != nil) + let lower = desc!.lowercased() + #expect(lower.contains("cache") || lower.contains("offline") || lower.contains("internet")) + } + + @Test("keychainError includes numeric status code") + func keychainError() { + let desc = BookStackError.keychainError(-25300).errorDescription + #expect(desc != nil) + #expect(desc!.contains("-25300")) + } + + @Test("sslError mentions SSL or TLS") + func sslError() { + let desc = BookStackError.sslError.errorDescription + #expect(desc != nil) + let upper = desc!.uppercased() + #expect(upper.contains("SSL") || upper.contains("TLS")) + } + + @Test("timeout mentions timed out or server") + func timeout() { + let desc = BookStackError.timeout.errorDescription + #expect(desc != nil) + #expect(!desc!.isEmpty) + } + + @Test("notReachable includes the host name") + func notReachable() { + let desc = BookStackError.notReachable(host: "wiki.company.com").errorDescription + #expect(desc != nil) + #expect(desc!.contains("wiki.company.com")) + } + + @Test("notBookStack includes the host name") + func notBookStack() { + let desc = BookStackError.notBookStack(host: "example.com").errorDescription + #expect(desc != nil) + #expect(desc!.contains("example.com")) + } + + @Test("unknown returns the provided message verbatim") + func unknown() { + let msg = "Something went very wrong" + let desc = BookStackError.unknown(msg).errorDescription + #expect(desc == msg) + } + + @Test("All cases produce non-nil, non-empty descriptions") + func allCasesHaveDescriptions() { + let errors: [BookStackError] = [ + .invalidURL, .notAuthenticated, .unauthorized, .forbidden, + .notFound(resource: "X"), .httpError(statusCode: 400, message: nil), + .decodingError("err"), .networkUnavailable, .keychainError(0), + .sslError, .timeout, .notReachable(host: "host"), + .notBookStack(host: "host"), .unknown("oops") + ] + for error in errors { + let desc = error.errorDescription + #expect(desc != nil, "nil description for \(error)") + #expect(!desc!.isEmpty, "empty description for \(error)") + } + } +} diff --git a/bookstaxTests/AccentThemeTests.swift b/bookstaxTests/AccentThemeTests.swift new file mode 100644 index 0000000..7adb312 --- /dev/null +++ b/bookstaxTests/AccentThemeTests.swift @@ -0,0 +1,168 @@ +import Testing +import SwiftUI +@testable import bookstax + +// MARK: - Color Hex Parsing + +@Suite("Color – Hex Parsing") +struct ColorHexParsingTests { + + // MARK: Valid 6-digit hex + + @Test("6-digit hex without # prefix parses successfully") + func sixDigitNoPound() { + #expect(Color(hex: "FF0000") != nil) + } + + @Test("6-digit hex with # prefix parses successfully") + func sixDigitWithPound() { + #expect(Color(hex: "#FF0000") != nil) + } + + @Test("Black hex #000000 parses successfully") + func blackHex() { + #expect(Color(hex: "#000000") != nil) + } + + @Test("White hex #FFFFFF parses successfully") + func whiteHex() { + #expect(Color(hex: "#FFFFFF") != nil) + } + + @Test("Lowercase hex parses successfully") + func lowercaseHex() { + #expect(Color(hex: "#ff6600") != nil) + } + + // MARK: Valid 3-digit hex + + @Test("3-digit hex #FFF is valid shorthand") + func threeDigitFFF() { + #expect(Color(hex: "#FFF") != nil) + } + + @Test("3-digit hex #000 is valid shorthand") + func threeDigitZero() { + #expect(Color(hex: "#000") != nil) + } + + @Test("3-digit hex #F60 is expanded to #FF6600") + func threeDigitExpansion() { + let threeDigit = Color(hex: "#F60") + let sixDigit = Color(hex: "#FF6600") + // Both should parse successfully + #expect(threeDigit != nil) + #expect(sixDigit != nil) + } + + // MARK: Invalid inputs + + @Test("5-digit hex returns nil") + func fiveDigitInvalid() { + #expect(Color(hex: "#FFFFF") == nil) + } + + @Test("7-digit hex returns nil") + func sevenDigitInvalid() { + #expect(Color(hex: "#FFFFFFF") == nil) + } + + @Test("Empty string returns nil") + func emptyStringInvalid() { + #expect(Color(hex: "") == nil) + } + + @Test("Non-hex characters return nil") + func nonHexCharacters() { + #expect(Color(hex: "#GGGGGG") == nil) + } + + @Test("Just # returns nil") + func onlyHash() { + #expect(Color(hex: "#") == nil) + } +} + +// MARK: - Color Hex Round-trip + +@Suite("Color – Hex Round-trip") +struct ColorHexRoundTripTests { + + @Test("Red #FF0000 round-trips correctly") + func redRoundTrip() { + let color = Color(hex: "#FF0000")! + let hex = color.toHexString() + #expect(hex == "#FF0000") + } + + @Test("Green #00FF00 round-trips correctly") + func greenRoundTrip() { + let color = Color(hex: "#00FF00")! + let hex = color.toHexString() + #expect(hex == "#00FF00") + } + + @Test("Blue #0000FF round-trips correctly") + func blueRoundTrip() { + let color = Color(hex: "#0000FF")! + let hex = color.toHexString() + #expect(hex == "#0000FF") + } + + @Test("Custom color #3A7B55 round-trips correctly") + func customColorRoundTrip() { + let color = Color(hex: "#3A7B55")! + let hex = color.toHexString() + #expect(hex == "#3A7B55") + } +} + +// MARK: - AccentTheme + +@Suite("AccentTheme – Properties") +struct AccentThemeTests { + + @Test("All cases are represented in CaseIterable") + func allCasesPresent() { + #expect(AccentTheme.allCases.count == 8) + } + + @Test("id equals rawValue") + func idEqualsRawValue() { + for theme in AccentTheme.allCases { + #expect(theme.id == theme.rawValue) + } + } + + @Test("Every theme has a non-empty displayName") + func displayNamesNonEmpty() { + for theme in AccentTheme.allCases { + #expect(!theme.displayName.isEmpty) + } + } + + @Test("accentColor equals shelfColor") + func accentColorEqualsShelfColor() { + for theme in AccentTheme.allCases { + #expect(theme.accentColor == theme.shelfColor) + } + } + + @Test("Ocean theme has expected displayName") + func oceanDisplayName() { + #expect(AccentTheme.ocean.displayName == "Ocean") + } + + @Test("Graphite theme has expected displayName") + func graphiteDisplayName() { + #expect(AccentTheme.graphite.displayName == "Graphite") + } + + @Test("All themes can be init'd from rawValue") + func initFromRawValue() { + for theme in AccentTheme.allCases { + let reinit = AccentTheme(rawValue: theme.rawValue) + #expect(reinit == theme) + } + } +} diff --git a/bookstaxTests/DTOTests.swift b/bookstaxTests/DTOTests.swift new file mode 100644 index 0000000..8aaa60d --- /dev/null +++ b/bookstaxTests/DTOTests.swift @@ -0,0 +1,235 @@ +import Testing +@testable import bookstax +import Foundation + +// MARK: - TagDTO + +@Suite("TagDTO") +struct TagDTOTests { + + @Test("id is composed of name and value") + func idFormat() { + let tag = TagDTO(name: "status", value: "published", order: 0) + #expect(tag.id == "status:published") + } + + @Test("id with empty value still includes colon separator") + func idEmptyValue() { + let tag = TagDTO(name: "featured", value: "", order: 0) + #expect(tag.id == "featured:") + } + + @Test("Two tags with same name/value have equal ids") + func duplicateIds() { + let t1 = TagDTO(name: "env", value: "prod", order: 0) + let t2 = TagDTO(name: "env", value: "prod", order: 1) + #expect(t1.id == t2.id) + } + + @Test("Tags decode from JSON correctly") + func decodeFromJSON() throws { + let json = """ + {"name":"topic","value":"swift","order":3} + """.data(using: .utf8)! + let tag = try JSONDecoder().decode(TagDTO.self, from: json) + #expect(tag.name == "topic") + #expect(tag.value == "swift") + #expect(tag.order == 3) + } +} + +// MARK: - PageDTO + +private let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f +}() + +private func pageJSON( + slug: String? = nil, + priority: Int? = nil, + draft: Bool? = nil, + tags: String? = nil, + markdown: String? = nil +) -> Data { + var fields: [String] = [ + #""id":1,"book_id":10,"name":"Test Page""#, + #""created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z""# + ] + if let s = slug { fields.append("\"slug\":\"\(s)\"") } + if let p = priority { fields.append("\"priority\":\(p)") } + if let d = draft { fields.append("\"draft\":\(d)") } + if let t = tags { fields.append("\"tags\":[\(t)]") } + if let m = markdown { fields.append("\"markdown\":\"\(m)\"") } + return ("{\(fields.joined(separator: ","))}").data(using: .utf8)! +} + +private func makeDecoder() -> JSONDecoder { + // Mirrors the decoder used in BookStackAPI + let decoder = JSONDecoder() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let str = try container.decode(String.self) + if let date = formatter.date(from: str) { return date } + // Fallback without fractional seconds + let fallback = ISO8601DateFormatter() + if let date = fallback.date(from: str) { return date } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(str)") + } + return decoder +} + +@Suite("PageDTO – JSON Decoding") +struct PageDTOTests { + + @Test("Minimal JSON (no optional fields) decodes with defaults") + func minimalDecoding() throws { + let data = pageJSON() + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.id == 1) + #expect(page.bookId == 10) + #expect(page.name == "Test Page") + #expect(page.slug == "") // defaults to "" + #expect(page.priority == 0) // defaults to 0 + #expect(page.draftStatus == false) // defaults to false + #expect(page.tags.isEmpty) // defaults to [] + #expect(page.markdown == nil) + #expect(page.html == nil) + #expect(page.chapterId == nil) + } + + @Test("slug is decoded when present") + func slugDecoded() throws { + let data = pageJSON(slug: "my-page") + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.slug == "my-page") + } + + @Test("priority is decoded when present") + func priorityDecoded() throws { + let data = pageJSON(priority: 5) + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.priority == 5) + } + + @Test("draft true decodes correctly") + func draftDecoded() throws { + let data = pageJSON(draft: true) + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.draftStatus == true) + } + + @Test("tags array is decoded when present") + func tagsDecoded() throws { + let tagJSON = #"{"name":"lang","value":"swift","order":0}"# + let data = pageJSON(tags: tagJSON) + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.tags.count == 1) + #expect(page.tags.first?.name == "lang") + } + + @Test("markdown is decoded when present") + func markdownDecoded() throws { + let data = pageJSON(markdown: "# Hello") + let page = try makeDecoder().decode(PageDTO.self, from: data) + #expect(page.markdown == "# Hello") + } +} + +// MARK: - SearchResultDTO + +@Suite("SearchResultDTO – JSON Decoding") +struct SearchResultDTOTests { + + private func resultJSON(withTags: Bool = false) -> Data { + let tags = withTags ? #","tags":[{"name":"topic","value":"swift","order":0}]"# : "" + return """ + {"id":7,"name":"Swift Basics","slug":"swift-basics","type":"page", + "url":"https://example.com/books/1/page/7","preview":"An intro to Swift"\(tags)} + """.data(using: .utf8)! + } + + @Test("Result with no tags field defaults to empty array") + func defaultsEmptyTags() throws { + let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON()) + #expect(result.tags.isEmpty) + } + + @Test("Result with tags decodes them correctly") + func decodesTagsWhenPresent() throws { + let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON(withTags: true)) + #expect(result.tags.count == 1) + } + + @Test("All fields decode correctly") + func allFieldsDecode() throws { + let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON()) + #expect(result.id == 7) + #expect(result.name == "Swift Basics") + #expect(result.slug == "swift-basics") + #expect(result.type == .page) + #expect(result.preview == "An intro to Swift") + } +} + +// MARK: - SearchResultDTO.ContentType + +@Suite("SearchResultDTO.ContentType") +struct ContentTypeTests { + + @Test("All content types have non-empty systemImage") + func systemImagesNonEmpty() { + for type_ in SearchResultDTO.ContentType.allCases { + #expect(!type_.systemImage.isEmpty) + } + } + + @Test("Page system image is doc.text") + func pageSystemImage() { + #expect(SearchResultDTO.ContentType.page.systemImage == "doc.text") + } + + @Test("Book system image is book.closed") + func bookSystemImage() { + #expect(SearchResultDTO.ContentType.book.systemImage == "book.closed") + } + + @Test("Chapter system image is list.bullet.rectangle") + func chapterSystemImage() { + #expect(SearchResultDTO.ContentType.chapter.systemImage == "list.bullet.rectangle") + } + + @Test("Shelf system image is books.vertical") + func shelfSystemImage() { + #expect(SearchResultDTO.ContentType.shelf.systemImage == "books.vertical") + } + + @Test("ContentType decodes from raw string value") + func rawValueDecoding() throws { + let data = #""page""#.data(using: .utf8)! + let type_ = try JSONDecoder().decode(SearchResultDTO.ContentType.self, from: data) + #expect(type_ == .page) + } +} + +// MARK: - PaginatedResponse + +@Suite("PaginatedResponse – Decoding") +struct PaginatedResponseTests { + + @Test("Paginated shelf response decodes total and data") + func paginatedDecoding() throws { + let json = """ + {"data":[{"id":1,"name":"My Shelf","slug":"my-shelf","description":"", + "created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z"}], + "total":1} + """.data(using: .utf8)! + let decoded = try makeDecoder().decode(PaginatedResponse.self, from: json) + #expect(decoded.total == 1) + #expect(decoded.data.count == 1) + #expect(decoded.data.first?.name == "My Shelf") + } +} diff --git a/bookstaxTests/DonationServiceTests.swift b/bookstaxTests/DonationServiceTests.swift new file mode 100644 index 0000000..d0f2c5e --- /dev/null +++ b/bookstaxTests/DonationServiceTests.swift @@ -0,0 +1,151 @@ +import Testing +@testable import bookstax +import Foundation + +// MARK: - DonationPurchaseState Enum + +@Suite("DonationPurchaseState – Computed Properties") +struct DonationPurchaseStateTests { + + @Test("activePurchasingID returns id when purchasing") + func activePurchasingID() { + let state = DonationPurchaseState.purchasing(productID: "donatebook") + #expect(state.activePurchasingID == "donatebook") + } + + @Test("activePurchasingID returns nil for non-purchasing states") + @available(iOS 18.0, *) + func activePurchasingIDNil() { + #expect(DonationPurchaseState.idle.activePurchasingID == nil) + #expect(DonationPurchaseState.thankYou(productID: "x").activePurchasingID == nil) + #expect(DonationPurchaseState.pending(productID: "x").activePurchasingID == nil) + #expect(DonationPurchaseState.failed(productID: "x", message: "err").activePurchasingID == nil) + } + + @Test("thankYouID returns id when in thankYou state") + func thankYouID() { + let state = DonationPurchaseState.thankYou(productID: "doneatepage") + #expect(state.thankYouID == "doneatepage") + } + + @Test("thankYouID returns nil for other states") + func thankYouIDNil() { + #expect(DonationPurchaseState.idle.thankYouID == nil) + #expect(DonationPurchaseState.purchasing(productID: "x").thankYouID == nil) + } + + @Test("pendingID returns id when in pending state") + func pendingID() { + let state = DonationPurchaseState.pending(productID: "donateencyclopaedia") + #expect(state.pendingID == "donateencyclopaedia") + } + + @Test("pendingID returns nil for other states") + func pendingIDNil() { + #expect(DonationPurchaseState.idle.pendingID == nil) + #expect(DonationPurchaseState.thankYou(productID: "x").pendingID == nil) + } + + @Test("errorMessage returns message when failed for matching id") + func errorMessageMatch() { + let state = DonationPurchaseState.failed(productID: "donatebook", message: "Payment declined") + #expect(state.errorMessage(for: "donatebook") == "Payment declined") + } + + @Test("errorMessage returns nil for wrong product id") + func errorMessageNoMatch() { + let state = DonationPurchaseState.failed(productID: "donatebook", message: "error") + #expect(state.errorMessage(for: "doneatepage") == nil) + } + + @Test("errorMessage returns nil for non-failed states") + func errorMessageNotFailed() { + #expect(DonationPurchaseState.idle.errorMessage(for: "x") == nil) + #expect(DonationPurchaseState.purchasing(productID: "x").errorMessage(for: "x") == nil) + } + + @Test("isIdle returns true only for .idle") + func isIdleCheck() { + #expect(DonationPurchaseState.idle.isIdle == true) + #expect(DonationPurchaseState.purchasing(productID: "x").isIdle == false) + #expect(DonationPurchaseState.thankYou(productID: "x").isIdle == false) + #expect(DonationPurchaseState.failed(productID: "x", message: "e").isIdle == false) + } + + @Test("Equatable: same purchasing states are equal") + func equatablePurchasing() { + #expect(DonationPurchaseState.purchasing(productID: "a") == DonationPurchaseState.purchasing(productID: "a")) + #expect(DonationPurchaseState.purchasing(productID: "a") != DonationPurchaseState.purchasing(productID: "b")) + } + + @Test("Equatable: idle equals idle") + func equatableIdle() { + #expect(DonationPurchaseState.idle == DonationPurchaseState.idle) + #expect(DonationPurchaseState.idle != DonationPurchaseState.purchasing(productID: "x")) + } +} + +// MARK: - shouldShowNudge Timing + +@Suite("DonationService – shouldShowNudge", .serialized) +@MainActor +struct DonationServiceNudgeTests { + + private let installKey = "bookstax.installDate" + private let nudgeKey = "bookstax.lastNudgeDate" + private let historyKey = "bookstax.donationHistory" + + init() { + // Clean UserDefaults before each test so DonationService reads fresh state + UserDefaults.standard.removeObject(forKey: installKey) + UserDefaults.standard.removeObject(forKey: nudgeKey) + UserDefaults.standard.removeObject(forKey: historyKey) + } + + @Test("Within 3-day grace period: nudge should not show") + func gracePeriodPreventsNudge() { + let recentInstall = Date().addingTimeInterval(-(2 * 24 * 3600)) // 2 days ago + UserDefaults.standard.set(recentInstall, forKey: installKey) + #expect(DonationService.shared.shouldShowNudge == false) + } + + @Test("After 3-day grace period: nudge should show") + func afterGracePeriodNudgeShows() { + let oldInstall = Date().addingTimeInterval(-(4 * 24 * 3600)) // 4 days ago + UserDefaults.standard.set(oldInstall, forKey: installKey) + #expect(DonationService.shared.shouldShowNudge == true) + } + + @Test("No install date recorded: nudge should not show (safe fallback)") + func noInstallDateFallback() { + // No install date stored — graceful fallback + #expect(DonationService.shared.shouldShowNudge == false) + } + + @Test("Nudge recently dismissed: should not show again") + func recentNudgeHidden() { + let oldInstall = Date().addingTimeInterval(-(90 * 24 * 3600)) + let recentNudge = Date().addingTimeInterval(-(10 * 24 * 3600)) // dismissed 10 days ago + UserDefaults.standard.set(oldInstall, forKey: installKey) + UserDefaults.standard.set(recentNudge, forKey: nudgeKey) + #expect(DonationService.shared.shouldShowNudge == false) + } + + @Test("Nudge dismissed ~6 months ago: should show again") + func sixMonthsLaterShowAgain() { + let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600)) + let oldNudge = Date().addingTimeInterval(-(183 * 24 * 3600)) // ~6 months ago + UserDefaults.standard.set(oldInstall, forKey: installKey) + UserDefaults.standard.set(oldNudge, forKey: nudgeKey) + #expect(DonationService.shared.shouldShowNudge == true) + } + + @Test("Nudge not yet 6 months: should not show") + func beforeSixMonthsHidden() { + let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600)) + let recentNudge = Date().addingTimeInterval(-(100 * 24 * 3600)) // only 100 days + UserDefaults.standard.set(oldInstall, forKey: installKey) + UserDefaults.standard.set(recentNudge, forKey: nudgeKey) + #expect(DonationService.shared.shouldShowNudge == false) + } +} diff --git a/bookstaxTests/OnboardingViewModelTests.swift b/bookstaxTests/OnboardingViewModelTests.swift new file mode 100644 index 0000000..d5a3abe --- /dev/null +++ b/bookstaxTests/OnboardingViewModelTests.swift @@ -0,0 +1,226 @@ +import Testing +@testable import bookstax + +// MARK: - URL Validation + +@Suite("OnboardingViewModel – URL Validation") +struct OnboardingViewModelURLTests { + + // MARK: Empty input + + @Test("Empty URL sets error and returns false") + func emptyURL() { + let vm = OnboardingViewModel() + vm.serverURLInput = "" + #expect(vm.validateServerURL() == false) + #expect(vm.serverURLError != nil) + } + + @Test("Whitespace-only URL sets error and returns false") + func whitespaceURL() { + let vm = OnboardingViewModel() + vm.serverURLInput = " " + #expect(vm.validateServerURL() == false) + #expect(vm.serverURLError != nil) + } + + // MARK: Auto-prefix https:// + + @Test("URL without scheme gets https:// prepended") + func autoprefixHTTPS() { + let vm = OnboardingViewModel() + vm.serverURLInput = "wiki.example.com" + let result = vm.validateServerURL() + #expect(result == true) + #expect(vm.serverURLInput.hasPrefix("https://")) + } + + @Test("URL already starting with https:// is left unchanged") + func httpsAlreadyPresent() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://wiki.example.com" + let result = vm.validateServerURL() + #expect(result == true) + #expect(vm.serverURLInput == "https://wiki.example.com") + } + + @Test("URL starting with http:// is left as-is (not double-prefixed)") + func httpNotDoublePrefixed() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://wiki.example.com" + let result = vm.validateServerURL() + #expect(result == true) + #expect(vm.serverURLInput == "http://wiki.example.com") + } + + // MARK: Trailing slash removal + + @Test("Trailing slash is stripped from URL") + func trailingSlash() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://wiki.example.com/" + _ = vm.validateServerURL() + #expect(vm.serverURLInput == "https://wiki.example.com") + } + + @Test("Multiple trailing slashes – only last slash stripped then accepted") + func multipleSlashesInPath() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://wiki.example.com/path/" + _ = vm.validateServerURL() + #expect(vm.serverURLInput == "https://wiki.example.com/path") + } + + // MARK: Successful validation + + @Test("Valid URL clears any previous error") + func validURLClearsError() { + let vm = OnboardingViewModel() + vm.serverURLError = "Previous error" + vm.serverURLInput = "https://books.mycompany.com" + #expect(vm.validateServerURL() == true) + #expect(vm.serverURLError == nil) + } +} + +// MARK: - isHTTP Flag + +@Suite("OnboardingViewModel – isHTTP") +struct OnboardingViewModelHTTPTests { + + @Test("http:// URL sets isHTTP = true") + func httpURL() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://wiki.local" + #expect(vm.isHTTP == true) + } + + @Test("https:// URL sets isHTTP = false") + func httpsURL() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://wiki.local" + #expect(vm.isHTTP == false) + } + + @Test("Empty URL is not HTTP") + func emptyNotHTTP() { + let vm = OnboardingViewModel() + vm.serverURLInput = "" + #expect(vm.isHTTP == false) + } +} + +// MARK: - isRemoteServer Detection + +@Suite("OnboardingViewModel – isRemoteServer") +struct OnboardingViewModelRemoteTests { + + // MARK: Local / private addresses + + @Test("localhost is not remote") + func localhost() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://localhost" + #expect(vm.isRemoteServer == false) + } + + @Test("127.0.0.1 is not remote") + func loopback() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://127.0.0.1" + #expect(vm.isRemoteServer == false) + } + + @Test("IPv6 loopback ::1 is not remote") + func ipv6Loopback() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://[::1]" + #expect(vm.isRemoteServer == false) + } + + @Test(".local mDNS host is not remote") + func mdnsLocal() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://bookstack.local" + #expect(vm.isRemoteServer == false) + } + + @Test("Plain hostname without dots is not remote") + func plainHostname() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://mywiki" + #expect(vm.isRemoteServer == false) + } + + @Test("10.x.x.x private range is not remote") + func privateClass_A() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://10.0.1.50" + #expect(vm.isRemoteServer == false) + } + + @Test("192.168.x.x private range is not remote") + func privateClass_C() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://192.168.1.100" + #expect(vm.isRemoteServer == false) + } + + @Test("172.16.x.x private range is not remote") + func privateClass_B_low() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://172.16.0.1" + #expect(vm.isRemoteServer == false) + } + + @Test("172.31.x.x private range is not remote") + func privateClass_B_high() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://172.31.255.255" + #expect(vm.isRemoteServer == false) + } + + @Test("172.15.x.x is outside private range and is remote") + func justBelowPrivateClass_B() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://172.15.0.1" + #expect(vm.isRemoteServer == true) + } + + @Test("172.32.x.x is outside private range and is remote") + func justAbovePrivateClass_B() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://172.32.0.1" + #expect(vm.isRemoteServer == true) + } + + // MARK: Remote addresses + + @Test("Public IP 8.8.8.8 is remote") + func publicIP() { + let vm = OnboardingViewModel() + vm.serverURLInput = "http://8.8.8.8" + #expect(vm.isRemoteServer == true) + } + + @Test("Public domain with subdomain is remote") + func publicDomain() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://wiki.mycompany.com" + #expect(vm.isRemoteServer == true) + } + + @Test("Top-level domain name is remote") + func topLevelDomain() { + let vm = OnboardingViewModel() + vm.serverURLInput = "https://bookstack.io" + #expect(vm.isRemoteServer == true) + } + + @Test("Empty URL is not remote") + func emptyIsNotRemote() { + let vm = OnboardingViewModel() + vm.serverURLInput = "" + #expect(vm.isRemoteServer == false) + } +} diff --git a/bookstaxTests/PageEditorViewModelTests.swift b/bookstaxTests/PageEditorViewModelTests.swift new file mode 100644 index 0000000..8e0623c --- /dev/null +++ b/bookstaxTests/PageEditorViewModelTests.swift @@ -0,0 +1,244 @@ +import Testing +@testable import bookstax +import Foundation + +// MARK: - Helpers + +private func makePageDTO(markdown: String? = "# Hello", tags: [TagDTO] = []) -> PageDTO { + PageDTO( + id: 42, + bookId: 1, + chapterId: nil, + name: "Test Page", + slug: "test-page", + html: nil, + markdown: markdown, + priority: 0, + draftStatus: false, + tags: tags, + createdAt: Date(), + updatedAt: Date() + ) +} + +// MARK: - Initialisation + +@Suite("PageEditorViewModel – Initialisation") +struct PageEditorViewModelInitTests { + + @Test("Create mode starts with empty title and content") + func createModeDefaults() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + #expect(vm.title.isEmpty) + #expect(vm.markdownContent.isEmpty) + #expect(vm.tags.isEmpty) + #expect(vm.isHtmlOnlyPage == false) + } + + @Test("Edit mode populates title and markdown from page") + func editModePopulates() { + let page = makePageDTO(markdown: "## Content") + let vm = PageEditorViewModel(mode: .edit(page: page)) + #expect(vm.title == "Test Page") + #expect(vm.markdownContent == "## Content") + #expect(vm.isHtmlOnlyPage == false) + } + + @Test("Edit mode with nil markdown sets isHtmlOnlyPage") + func htmlOnlyPage() { + let page = makePageDTO(markdown: nil) + let vm = PageEditorViewModel(mode: .edit(page: page)) + #expect(vm.isHtmlOnlyPage == true) + #expect(vm.markdownContent.isEmpty) + } + + @Test("Edit mode with existing tags loads them") + func editModeLoadsTags() { + let tags = [TagDTO(name: "topic", value: "swift", order: 0)] + let page = makePageDTO(tags: tags) + let vm = PageEditorViewModel(mode: .edit(page: page)) + #expect(vm.tags.count == 1) + #expect(vm.tags.first?.name == "topic") + } +} + +// MARK: - hasUnsavedChanges + +@Suite("PageEditorViewModel – hasUnsavedChanges") +struct PageEditorViewModelUnsavedTests { + + @Test("No changes after init → hasUnsavedChanges is false") + func noChangesAfterInit() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + #expect(vm.hasUnsavedChanges == false) + } + + @Test("Changing title → hasUnsavedChanges is true") + func titleChange() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.title = "New Title" + #expect(vm.hasUnsavedChanges == true) + } + + @Test("Changing markdownContent → hasUnsavedChanges is true") + func contentChange() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.markdownContent = "Some text" + #expect(vm.hasUnsavedChanges == true) + } + + @Test("Adding a tag → hasUnsavedChanges is true") + func tagAddition() { + let page = makePageDTO() + let vm = PageEditorViewModel(mode: .edit(page: page)) + vm.addTag(name: "new-tag") + #expect(vm.hasUnsavedChanges == true) + } + + @Test("Restoring original values → hasUnsavedChanges is false again") + func revertChanges() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.title = "Changed" + vm.title = "" + #expect(vm.hasUnsavedChanges == false) + } +} + +// MARK: - isSaveDisabled + +@Suite("PageEditorViewModel – isSaveDisabled") +struct PageEditorViewModelSaveDisabledTests { + + @Test("Empty title disables save in create mode") + func emptyTitleCreate() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.markdownContent = "Some content" + #expect(vm.isSaveDisabled == true) + } + + @Test("Empty content disables save in create mode even if title is set") + func emptyContentCreate() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.title = "My Page" + vm.markdownContent = "" + #expect(vm.isSaveDisabled == true) + } + + @Test("Whitespace-only content disables save in create mode") + func whitespaceContentCreate() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.title = "My Page" + vm.markdownContent = " \n " + #expect(vm.isSaveDisabled == true) + } + + @Test("Title and content both set enables save in create mode") + func validCreate() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.title = "My Page" + vm.markdownContent = "Hello world" + #expect(vm.isSaveDisabled == false) + } + + @Test("Edit mode only requires title – empty content is allowed") + func editOnlyNeedsTitle() { + let page = makePageDTO(markdown: nil) + let vm = PageEditorViewModel(mode: .edit(page: page)) + vm.title = "Existing Page" + vm.markdownContent = "" + #expect(vm.isSaveDisabled == false) + } + + @Test("Empty title disables save in edit mode") + func emptyTitleEdit() { + let page = makePageDTO() + let vm = PageEditorViewModel(mode: .edit(page: page)) + vm.title = "" + #expect(vm.isSaveDisabled == true) + } +} + +// MARK: - Tag Management + +@Suite("PageEditorViewModel – Tags") +struct PageEditorViewModelTagTests { + + @Test("addTag appends a new tag") + func addTag() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: "status", value: "draft") + #expect(vm.tags.count == 1) + #expect(vm.tags.first?.name == "status") + #expect(vm.tags.first?.value == "draft") + } + + @Test("addTag trims whitespace from name and value") + func addTagTrims() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: " topic ", value: " swift ") + #expect(vm.tags.first?.name == "topic") + #expect(vm.tags.first?.value == "swift") + } + + @Test("addTag with empty name after trimming is ignored") + func addTagEmptyNameIgnored() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: " ") + #expect(vm.tags.isEmpty) + } + + @Test("addTag prevents duplicate (same name + value) entries") + func addTagNoDuplicates() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: "lang", value: "swift") + vm.addTag(name: "lang", value: "swift") + #expect(vm.tags.count == 1) + } + + @Test("addTag allows same name with different value") + func addTagSameNameDifferentValue() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: "env", value: "dev") + vm.addTag(name: "env", value: "prod") + #expect(vm.tags.count == 2) + } + + @Test("removeTag removes the matching tag by id") + func removeTag() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: "remove-me", value: "yes") + let tag = vm.tags[0] + vm.removeTag(tag) + #expect(vm.tags.isEmpty) + } + + @Test("removeTag does not remove non-matching tags") + func removeTagKeepsOthers() { + let vm = PageEditorViewModel(mode: .create(bookId: 1)) + vm.addTag(name: "keep", value: "") + vm.addTag(name: "remove", value: "") + let toRemove = vm.tags.first { $0.name == "remove" }! + vm.removeTag(toRemove) + #expect(vm.tags.count == 1) + #expect(vm.tags.first?.name == "keep") + } +} + +// MARK: - uploadTargetPageId + +@Suite("PageEditorViewModel – uploadTargetPageId") +struct PageEditorViewModelUploadIDTests { + + @Test("Create mode returns 0 as upload target") + func createModeUploadTarget() { + let vm = PageEditorViewModel(mode: .create(bookId: 5)) + #expect(vm.uploadTargetPageId == 0) + } + + @Test("Edit mode returns the existing page id") + func editModeUploadTarget() { + let page = makePageDTO() + let vm = PageEditorViewModel(mode: .edit(page: page)) + #expect(vm.uploadTargetPageId == 42) + } +} diff --git a/bookstaxTests/SearchViewModelTests.swift b/bookstaxTests/SearchViewModelTests.swift new file mode 100644 index 0000000..e05f6e0 --- /dev/null +++ b/bookstaxTests/SearchViewModelTests.swift @@ -0,0 +1,108 @@ +import Testing +@testable import bookstax + +// MARK: - Recent Searches + +@Suite("SearchViewModel – Recent Searches", .serialized) +struct SearchViewModelRecentTests { + + private let recentKey = "recentSearches" + + init() { + // Start each test with a clean slate + UserDefaults.standard.removeObject(forKey: recentKey) + } + + // MARK: addToRecent + + @Test("Adding a query inserts it at position 0") + func addInsertsAtFront() { + let vm = SearchViewModel() + vm.addToRecent("swift") + vm.addToRecent("swiftui") + let recent = vm.recentSearches + #expect(recent.first == "swiftui") + #expect(recent[1] == "swift") + } + + @Test("Adding duplicate moves it to front without creating duplicates") + func addDeduplicates() { + let vm = SearchViewModel() + vm.addToRecent("bookstack") + vm.addToRecent("wiki") + vm.addToRecent("bookstack") // duplicate + let recent = vm.recentSearches + #expect(recent.first == "bookstack") + #expect(recent.count == 2) + #expect(!recent.dropFirst().contains("bookstack")) + } + + @Test("List is capped at 10 entries") + func cappedAtTen() { + let vm = SearchViewModel() + for i in 1...12 { + vm.addToRecent("query\(i)") + } + #expect(vm.recentSearches.count == 10) + } + + @Test("Oldest entries are dropped when cap is exceeded") + func oldestDropped() { + let vm = SearchViewModel() + for i in 1...11 { + vm.addToRecent("query\(i)") + } + let recent = vm.recentSearches + // query1 was added first, so it falls off after 11 adds + #expect(!recent.contains("query1")) + #expect(recent.contains("query11")) + } + + // MARK: clearRecentSearches + + @Test("clearRecentSearches empties the list") + func clearResetsToEmpty() { + let vm = SearchViewModel() + vm.addToRecent("something") + vm.clearRecentSearches() + #expect(vm.recentSearches.isEmpty) + } + + // MARK: Persistence + + @Test("Recent searches persist across ViewModel instances") + func persistsAcrossInstances() { + let vm1 = SearchViewModel() + vm1.addToRecent("persistent") + + let vm2 = SearchViewModel() + #expect(vm2.recentSearches.contains("persistent")) + } +} + +// MARK: - Query Minimum Length + +@Suite("SearchViewModel – Query Logic") +struct SearchViewModelQueryTests { + + @Test("Short query clears results without triggering search") + func shortQueryClearsResults() { + let vm = SearchViewModel() + vm.results = [SearchResultDTO(id: 1, name: "dummy", slug: "dummy", + type: .page, url: "", preview: nil)] + vm.query = "x" // only 1 character + vm.onQueryChanged() + #expect(vm.results.isEmpty) + } + + @Test("Query with 2+ chars does not clear results immediately") + func sufficientQueryKeepsResults() { + let vm = SearchViewModel() + vm.query = "sw" // 2 characters — triggers debounce but does not clear + vm.onQueryChanged() + // Results not cleared by onQueryChanged when query is long enough + // (actual search would require API; here we just verify results aren't wiped) + // Results were empty to start with, so we just confirm no crash + #expect(vm.results.isEmpty) // no API call, so still empty + } +} diff --git a/bookstaxTests/StringHTMLTests.swift b/bookstaxTests/StringHTMLTests.swift new file mode 100644 index 0000000..a88336a --- /dev/null +++ b/bookstaxTests/StringHTMLTests.swift @@ -0,0 +1,88 @@ +import Testing +@testable import bookstax + +@Suite("String – strippingHTML") +struct StringHTMLTests { + + // MARK: Basic tag removal + + @Test("Simple tag is removed") + func simpleTag() { + #expect("

Hello

".strippingHTML == "Hello") + } + + @Test("Bold tag is removed") + func boldTag() { + #expect("Bold".strippingHTML == "Bold") + } + + @Test("Nested tags are fully stripped") + func nestedTags() { + #expect("

Deep

".strippingHTML == "Deep") + } + + @Test("Self-closing tags are removed") + func selfClosingTag() { + let result = "Before
After".strippingHTML + // NSAttributedString adds a newline for
, so just check both words are present + #expect(result.contains("Before")) + #expect(result.contains("After")) + } + + // MARK: HTML entities + + @Test("& decodes to &") + func ampersandEntity() { + #expect("Cats & Dogs".strippingHTML == "Cats & Dogs") + } + + @Test("< and > decode to < and >") + func angleEntities() { + #expect("<tag>".strippingHTML == "") + } + + @Test("  is decoded (non-empty result)") + func nbspEntity() { + let result = "Hello World".strippingHTML + #expect(!result.isEmpty) + #expect(result.contains("Hello")) + #expect(result.contains("World")) + } + + @Test("" decodes to double quote") + func quotEntity() { + #expect("Say "hi"".strippingHTML == "Say \"hi\"") + } + + // MARK: Edge cases + + @Test("Empty string returns empty string") + func emptyString() { + #expect("".strippingHTML == "") + } + + @Test("Plain text without HTML is returned unchanged") + func plainText() { + #expect("No tags here".strippingHTML == "No tags here") + } + + @Test("Leading and trailing whitespace is trimmed") + func trimmingWhitespace() { + #expect("

hello

".strippingHTML == "hello") + } + + @Test("HTML with attributes strips fully") + func tagsWithAttributes() { + let html = "Click here" + #expect(html.strippingHTML == "Click here") + } + + @Test("Complex real-world snippet is reduced to plain text") + func complexSnippet() { + let html = "

Title

First paragraph.

  • Item 1
" + let result = html.strippingHTML + #expect(result.contains("Title")) + #expect(result.contains("First paragraph")) + #expect(result.contains("Item 1")) + } +}