Nudge-Screen

This commit is contained in:
2026-04-20 09:41:18 +02:00
parent a48e857ada
commit 187c3e4fc6
26 changed files with 2542 additions and 229 deletions
+166 -4
View File
@@ -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 = "<group>"; };
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = "<group>"; };
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = "<group>"; };
57AC406884F8446C6F4FA215 /* DTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DTOTests.swift; path = bookstaxTests/DTOTests.swift; sourceTree = "<group>"; };
944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIErrorTests.swift; path = bookstaxTests/APIErrorTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
261299CD2F6C686D00EC1C97 = {
isa = PBXGroup;
children = (
@@ -42,6 +87,8 @@
261299D82F6C686D00EC1C97 /* bookstax */,
261299D72F6C686D00EC1C97 /* Products */,
26FD17072F8A9643006E87F3 /* Donations.storekit */,
EB2578937899373803DA341A /* Frameworks */,
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */,
);
sourceTree = "<group>";
};
@@ -49,10 +96,34 @@
isa = PBXGroup;
children = (
261299D62F6C686D00EC1C97 /* bookstax.app */,
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
EB2578937899373803DA341A /* Frameworks */ = {
isa = PBXGroup;
children = (
1BB5D3095A0460024F7BA321 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* 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 */;
@@ -21,6 +21,20 @@
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AD8774751A52779622D7AED5"
BuildableName = "bookstaxTests.xctest"
BlueprintName = "bookstaxTests"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
@@ -28,7 +42,19 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldAutocreateTestPlan = "NO">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AD8774751A52779622D7AED5"
BuildableName = "bookstaxTests.xctest"
BlueprintName = "bookstaxTests"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
+37 -1
View File
@@ -11,7 +11,7 @@ enum DonationLoadState {
case failed(String)
}
enum DonationPurchaseState {
enum DonationPurchaseState: Equatable {
case idle
case purchasing(productID: String)
case thankYou(productID: String)
@@ -68,10 +68,46 @@ final class DonationService {
private var transactionListenerTask: Task<Void, Never>?
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
+2 -55
View File
@@ -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: "")
}
@@ -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)
+9 -8
View File
@@ -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 = """
<!DOCTYPE html><html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -644,10 +649,6 @@ struct MarkdownPreviewView: View {
</head>
<body>\(html)</body></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.
+1 -9
View File
@@ -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()
+25 -15
View File
@@ -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
}
}
}
+2 -93
View File
@@ -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()
}
}
+4 -14
View File
@@ -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 ?? "<p><em>\(L("reader.nocontent"))</em></p>")
webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!)
htmlContent = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
}
private func loadComments() async {
+26 -24
View File
@@ -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
@@ -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)
}
}
+53
View File
@@ -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)
}
}
}
}
+15
View File
@@ -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";
+15
View File
@@ -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";
+15
View File
@@ -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";
+297
View File
@@ -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é";
+297
View File
@@ -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é";
+129
View File
@@ -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)")
}
}
}
+168
View File
@@ -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)
}
}
}
+235
View File
@@ -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<ShelfDTO>.self, from: json)
#expect(decoded.total == 1)
#expect(decoded.data.count == 1)
#expect(decoded.data.first?.name == "My Shelf")
}
}
+151
View File
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
+108
View File
@@ -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
}
}
+88
View File
@@ -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("<p>Hello</p>".strippingHTML == "Hello")
}
@Test("Bold tag is removed")
func boldTag() {
#expect("<b>Bold</b>".strippingHTML == "Bold")
}
@Test("Nested tags are fully stripped")
func nestedTags() {
#expect("<div><p><span>Deep</span></p></div>".strippingHTML == "Deep")
}
@Test("Self-closing tags are removed")
func selfClosingTag() {
let result = "Before<br/>After".strippingHTML
// NSAttributedString adds a newline for <br>, so just check both words are present
#expect(result.contains("Before"))
#expect(result.contains("After"))
}
// MARK: HTML entities
@Test("&amp; decodes to &")
func ampersandEntity() {
#expect("Cats &amp; Dogs".strippingHTML == "Cats & Dogs")
}
@Test("&lt; and &gt; decode to < and >")
func angleEntities() {
#expect("&lt;tag&gt;".strippingHTML == "<tag>")
}
@Test("&nbsp; is decoded (non-empty result)")
func nbspEntity() {
let result = "Hello&nbsp;World".strippingHTML
#expect(!result.isEmpty)
#expect(result.contains("Hello"))
#expect(result.contains("World"))
}
@Test("&quot; decodes to double quote")
func quotEntity() {
#expect("Say &quot;hi&quot;".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("<p> hello </p>".strippingHTML == "hello")
}
@Test("HTML with attributes strips fully")
func tagsWithAttributes() {
let html = "<a href=\"https://example.com\" class=\"link\">Click here</a>"
#expect(html.strippingHTML == "Click here")
}
@Test("Complex real-world snippet is reduced to plain text")
func complexSnippet() {
let html = "<h1>Title</h1><p>First paragraph.</p><ul><li>Item 1</li></ul>"
let result = html.strippingHTML
#expect(result.contains("Title"))
#expect(result.contains("First paragraph"))
#expect(result.contains("Item 1"))
}
}