Nudge-Screen
This commit is contained in:
@@ -7,13 +7,42 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
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 */
|
/* 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 */
|
/* 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; };
|
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>"; };
|
26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = "<group>"; };
|
||||||
26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -25,6 +54,14 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
1A1B96526910505E82E2CFDB /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
261299D32F6C686D00EC1C97 /* Frameworks */ = {
|
261299D32F6C686D00EC1C97 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -35,6 +72,14 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
1BB5D3095A0460024F7BA321 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0841C05048CA5AE635439A8 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
261299CD2F6C686D00EC1C97 = {
|
261299CD2F6C686D00EC1C97 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -42,6 +87,8 @@
|
|||||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||||
261299D72F6C686D00EC1C97 /* Products */,
|
261299D72F6C686D00EC1C97 /* Products */,
|
||||||
26FD17072F8A9643006E87F3 /* Donations.storekit */,
|
26FD17072F8A9643006E87F3 /* Donations.storekit */,
|
||||||
|
EB2578937899373803DA341A /* Frameworks */,
|
||||||
|
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -49,10 +96,34 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
261299D62F6C686D00EC1C97 /* bookstax.app */,
|
261299D62F6C686D00EC1C97 /* bookstax.app */,
|
||||||
|
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -72,12 +143,28 @@
|
|||||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||||
);
|
);
|
||||||
name = bookstax;
|
name = bookstax;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = bookstax;
|
productName = bookstax;
|
||||||
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -101,6 +188,7 @@
|
|||||||
Base,
|
Base,
|
||||||
de,
|
de,
|
||||||
es,
|
es,
|
||||||
|
fr,
|
||||||
);
|
);
|
||||||
mainGroup = 261299CD2F6C686D00EC1C97;
|
mainGroup = 261299CD2F6C686D00EC1C97;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -110,6 +198,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
261299D52F6C686D00EC1C97 /* bookstax */,
|
261299D52F6C686D00EC1C97 /* bookstax */,
|
||||||
|
AD8774751A52779622D7AED5 /* bookstaxTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -123,6 +212,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA28FE166C71A3A60AC62034 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -133,9 +229,49 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = bookstax;
|
||||||
|
target = 261299D52F6C686D00EC1C97 /* bookstax */;
|
||||||
|
targetProxy = 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
261299DF2F6C686E00EC1C97 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -268,11 +404,12 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -299,11 +436,12 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -319,6 +457,21 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -340,6 +493,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1C68E5D77B468BD3A7F1C349 /* Release */,
|
||||||
|
C9DF29CF9FF31B97AC4E31E5 /* Debug */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 261299CE2F6C686D00EC1C97 /* Project object */;
|
rootObject = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||||
|
|||||||
@@ -21,6 +21,20 @@
|
|||||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</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>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
@@ -28,7 +42,19 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
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>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ enum DonationLoadState {
|
|||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DonationPurchaseState {
|
enum DonationPurchaseState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case purchasing(productID: String)
|
case purchasing(productID: String)
|
||||||
case thankYou(productID: String)
|
case thankYou(productID: String)
|
||||||
@@ -68,10 +68,46 @@ final class DonationService {
|
|||||||
|
|
||||||
private var transactionListenerTask: Task<Void, Never>?
|
private var transactionListenerTask: Task<Void, Never>?
|
||||||
private static let historyDefaultsKey = "bookstax.donationHistory"
|
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() {
|
private init() {
|
||||||
donationHistory = Self.loadPersistedHistory()
|
donationHistory = Self.loadPersistedHistory()
|
||||||
transactionListenerTask = startTransactionListener()
|
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
|
// MARK: - Product Loading
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
|
|
||||||
/// Manages in-app language selection independently of the system locale.
|
/// Returns the localized string for the given key using the device system language.
|
||||||
@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
|
|
||||||
func L(_ key: String) -> String {
|
func L(_ key: String) -> String {
|
||||||
LanguageManager.shared.string(key)
|
NSLocalizedString(key, comment: "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import Observation
|
|||||||
@Observable
|
@Observable
|
||||||
final class OnboardingViewModel {
|
final class OnboardingViewModel {
|
||||||
|
|
||||||
enum Step: Int, CaseIterable, Hashable {
|
enum Step: Hashable {
|
||||||
case language = 0
|
case welcome
|
||||||
case welcome = 1
|
case connect
|
||||||
case connect = 2
|
case ready
|
||||||
case ready = 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation — NavigationStack path (language is the root, not in the path)
|
// Navigation — NavigationStack path (language is the root, not in the path)
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ struct PageEditorView: View {
|
|||||||
private func replace(in tv: UITextView, range: NSRange, with string: String,
|
private func replace(in tv: UITextView, range: NSRange, with string: String,
|
||||||
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
|
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
|
||||||
guard let swiftRange = Range(range, in: tv.text) else { return }
|
guard let swiftRange = Range(range, in: tv.text) else { return }
|
||||||
var newText = tv.text!
|
var newText = tv.text ?? ""
|
||||||
newText.replaceSubrange(swiftRange, with: string)
|
newText.replaceSubrange(swiftRange, with: string)
|
||||||
tv.text = newText
|
tv.text = newText
|
||||||
viewModel.markdownContent = newText
|
viewModel.markdownContent = newText
|
||||||
@@ -611,11 +611,16 @@ struct FormatButton: View {
|
|||||||
|
|
||||||
struct MarkdownPreviewView: View {
|
struct MarkdownPreviewView: View {
|
||||||
let markdown: String
|
let markdown: String
|
||||||
@State private var webPage = WebPage()
|
@State private var htmlContent: String = ""
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@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 {
|
var body: some View {
|
||||||
WebView(webPage)
|
HTMLWebView(html: htmlContent, baseURL: serverBaseURL, openLinksExternally: false)
|
||||||
.onAppear { loadPreview() }
|
.onAppear { loadPreview() }
|
||||||
.onChange(of: markdown) { loadPreview() }
|
.onChange(of: markdown) { loadPreview() }
|
||||||
.onChange(of: colorScheme) { loadPreview() }
|
.onChange(of: colorScheme) { loadPreview() }
|
||||||
@@ -628,7 +633,7 @@ struct MarkdownPreviewView: View {
|
|||||||
let fg = isDark ? "#f2f2f7" : "#000000"
|
let fg = isDark ? "#f2f2f7" : "#000000"
|
||||||
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
|
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
|
||||||
|
|
||||||
let fullHTML = """
|
htmlContent = """
|
||||||
<!DOCTYPE html><html>
|
<!DOCTYPE html><html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -644,10 +649,6 @@ struct MarkdownPreviewView: View {
|
|||||||
</head>
|
</head>
|
||||||
<body>\(html)</body></html>
|
<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.
|
/// Minimal Markdown → HTML converter for preview purposes.
|
||||||
|
|||||||
@@ -172,15 +172,7 @@ struct BookDetailView: View {
|
|||||||
NavigationLink(value: page) {
|
NavigationLink(value: page) {
|
||||||
Label(L("book.open"), systemImage: "arrow.up.right.square")
|
Label(L("book.open"), systemImage: "arrow.up.right.square")
|
||||||
}
|
}
|
||||||
Button {
|
ShareLink(item: "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)") {
|
||||||
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: {
|
|
||||||
Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
|
Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
|
|||||||
@@ -3,25 +3,23 @@ import SwiftUI
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@Environment(ConnectivityMonitor.self) private var connectivity
|
@Environment(ConnectivityMonitor.self) private var connectivity
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
@State private var showNudge = false
|
||||||
private let navState = AppNavigationState.shared
|
private let navState = AppNavigationState.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
Tab(L("tab.library"), systemImage: "books.vertical", value: 0) {
|
LibraryView()
|
||||||
LibraryView()
|
.tabItem { Label(L("tab.library"), systemImage: "books.vertical") }
|
||||||
}
|
.tag(0)
|
||||||
|
QuickNoteView()
|
||||||
Tab(L("tab.quicknote"), systemImage: "square.and.pencil", value: 1) {
|
.tabItem { Label(L("tab.quicknote"), systemImage: "square.and.pencil") }
|
||||||
QuickNoteView()
|
.tag(1)
|
||||||
}
|
SearchView()
|
||||||
|
.tabItem { Label(L("tab.search"), systemImage: "magnifyingglass") }
|
||||||
Tab(L("tab.search"), systemImage: "magnifyingglass", value: 2) {
|
.tag(2)
|
||||||
SearchView()
|
SettingsView()
|
||||||
}
|
.tabItem { Label(L("tab.settings"), systemImage: "gear") }
|
||||||
|
.tag(3)
|
||||||
Tab(L("tab.settings"), systemImage: "gear", value: 3) {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: navState.pendingBookNavigation) { _, book in
|
.onChange(of: navState.pendingBookNavigation) { _, book in
|
||||||
if book != nil { selectedTab = 0 }
|
if book != nil { selectedTab = 0 }
|
||||||
@@ -29,6 +27,18 @@ struct MainTabView: View {
|
|||||||
.onChange(of: navState.navigateToSettings) { _, go in
|
.onChange(of: navState.navigateToSettings) { _, go in
|
||||||
if go { selectedTab = 3; navState.navigateToSettings = false }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@State private var viewModel = OnboardingViewModel()
|
@State private var viewModel = OnboardingViewModel()
|
||||||
@State private var langManager = LanguageManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -12,7 +11,7 @@ struct OnboardingView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
} else {
|
} else {
|
||||||
NavigationStack(path: $viewModel.navPath) {
|
NavigationStack(path: $viewModel.navPath) {
|
||||||
LanguageStepView(viewModel: viewModel)
|
WelcomeStepView(viewModel: viewModel)
|
||||||
.navigationDestination(for: OnboardingViewModel.Step.self) { step in
|
.navigationDestination(for: OnboardingViewModel.Step.self) { step in
|
||||||
switch step {
|
switch step {
|
||||||
case .welcome:
|
case .welcome:
|
||||||
@@ -21,102 +20,12 @@ struct OnboardingView: View {
|
|||||||
ConnectStepView(viewModel: viewModel)
|
ConnectStepView(viewModel: viewModel)
|
||||||
case .ready:
|
case .ready:
|
||||||
ReadyStepView(onComplete: viewModel.completeOnboarding)
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
|
||||||
|
|
||||||
struct PageReaderView: View {
|
struct PageReaderView: View {
|
||||||
let page: PageDTO
|
let page: PageDTO
|
||||||
@State private var webPage = WebPage()
|
@State private var htmlContent: String = ""
|
||||||
@State private var fullPage: PageDTO? = nil
|
@State private var fullPage: PageDTO? = nil
|
||||||
@State private var isLoadingPage = false
|
@State private var isLoadingPage = false
|
||||||
@State private var comments: [CommentDTO] = []
|
@State private var comments: [CommentDTO] = []
|
||||||
@@ -43,7 +42,7 @@ struct PageReaderView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Web content — fills all space not taken by the comments inset
|
// 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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -96,15 +95,7 @@ struct PageReaderView: View {
|
|||||||
.accessibilityLabel(L("reader.edit"))
|
.accessibilityLabel(L("reader.edit"))
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") {
|
||||||
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: {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L("reader.share"))
|
.accessibilityLabel(L("reader.share"))
|
||||||
@@ -243,8 +234,7 @@ struct PageReaderView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadContent() {
|
private func loadContent() {
|
||||||
let html = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
|
htmlContent = 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")!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadComments() async {
|
private func loadComments() async {
|
||||||
|
|||||||
@@ -8,19 +8,20 @@ struct SettingsView: View {
|
|||||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
||||||
@AppStorage("loggingEnabled") private var loggingEnabled = false
|
@AppStorage("loggingEnabled") private var loggingEnabled = false
|
||||||
@Environment(ServerProfileStore.self) private var profileStore
|
@Environment(ServerProfileStore.self) private var profileStore
|
||||||
|
@State private var donationService = DonationService.shared
|
||||||
|
|
||||||
private var selectedTheme: AccentTheme {
|
private var selectedTheme: AccentTheme {
|
||||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||||
}
|
}
|
||||||
@State private var showSignOutAlert = false
|
@State private var showSignOutAlert = false
|
||||||
@State private var showSafari: URL? = nil
|
@State private var showSafari: URL? = nil
|
||||||
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
|
|
||||||
@State private var showLogViewer = false
|
@State private var showLogViewer = false
|
||||||
@State private var shareItems: [Any]? = nil
|
@State private var shareItems: [Any]? = nil
|
||||||
@State private var showAddServer = false
|
@State private var showAddServer = false
|
||||||
@State private var profileToSwitch: ServerProfile? = nil
|
@State private var profileToSwitch: ServerProfile? = nil
|
||||||
@State private var profileToDelete: ServerProfile? = nil
|
@State private var profileToDelete: ServerProfile? = nil
|
||||||
@State private var profileToEdit: 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 appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||||
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||||
@@ -28,29 +29,6 @@ struct SettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
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
|
// Appearance section
|
||||||
Section(L("settings.appearance")) {
|
Section(L("settings.appearance")) {
|
||||||
Picker(L("settings.appearance.theme"), selection: $appTheme) {
|
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
|
// Donate section
|
||||||
DonationSectionView()
|
DonationSectionView()
|
||||||
|
|
||||||
@@ -253,6 +252,9 @@ struct SettingsView: View {
|
|||||||
Text(String(format: L("settings.servers.delete.active.message"), p.name))
|
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(isPresented: $showAddServer) { AddServerView() }
|
||||||
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
|
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
|
||||||
.sheet(item: $showSafari) { url in
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,12 @@
|
|||||||
"settings.reader" = "Leser";
|
"settings.reader" = "Leser";
|
||||||
"settings.reader.showcomments" = "Kommentare anzeigen";
|
"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
|
// MARK: - Logging
|
||||||
"settings.log" = "Protokoll";
|
"settings.log" = "Protokoll";
|
||||||
"settings.log.enabled" = "Protokollierung aktivieren";
|
"settings.log.enabled" = "Protokollierung aktivieren";
|
||||||
@@ -274,6 +280,15 @@
|
|||||||
"settings.donate.donated.on" = "Gespendet am %@";
|
"settings.donate.donated.on" = "Gespendet am %@";
|
||||||
"settings.donate.pending" = "Ausstehende Bestätigung…";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.cancel" = "Abbrechen";
|
"common.cancel" = "Abbrechen";
|
||||||
|
|||||||
@@ -219,6 +219,12 @@
|
|||||||
"settings.reader" = "Reader";
|
"settings.reader" = "Reader";
|
||||||
"settings.reader.showcomments" = "Show Comments";
|
"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
|
// MARK: - Logging
|
||||||
"settings.log" = "Logging";
|
"settings.log" = "Logging";
|
||||||
"settings.log.enabled" = "Enable Logging";
|
"settings.log.enabled" = "Enable Logging";
|
||||||
@@ -274,6 +280,15 @@
|
|||||||
"settings.donate.donated.on" = "Donated on %@";
|
"settings.donate.donated.on" = "Donated on %@";
|
||||||
"settings.donate.pending" = "Pending confirmation…";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
"common.cancel" = "Cancel";
|
"common.cancel" = "Cancel";
|
||||||
|
|||||||
@@ -219,6 +219,12 @@
|
|||||||
"settings.reader" = "Lector";
|
"settings.reader" = "Lector";
|
||||||
"settings.reader.showcomments" = "Mostrar comentarios";
|
"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
|
// MARK: - Logging
|
||||||
"settings.log" = "Registro";
|
"settings.log" = "Registro";
|
||||||
"settings.log.enabled" = "Activar registro";
|
"settings.log.enabled" = "Activar registro";
|
||||||
@@ -274,6 +280,15 @@
|
|||||||
"settings.donate.donated.on" = "Donado el %@";
|
"settings.donate.donated.on" = "Donado el %@";
|
||||||
"settings.donate.pending" = "Confirmación pendiente…";
|
"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
|
// MARK: - Common
|
||||||
"common.ok" = "Aceptar";
|
"common.ok" = "Aceptar";
|
||||||
"common.cancel" = "Cancelar";
|
"common.cancel" = "Cancelar";
|
||||||
|
|||||||
+297
@@ -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é";
|
||||||
@@ -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é";
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("& decodes to &")
|
||||||
|
func ampersandEntity() {
|
||||||
|
#expect("Cats & Dogs".strippingHTML == "Cats & Dogs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("< and > decode to < and >")
|
||||||
|
func angleEntities() {
|
||||||
|
#expect("<tag>".strippingHTML == "<tag>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(" is decoded (non-empty result)")
|
||||||
|
func nbspEntity() {
|
||||||
|
let result = "Hello World".strippingHTML
|
||||||
|
#expect(!result.isEmpty)
|
||||||
|
#expect(result.contains("Hello"))
|
||||||
|
#expect(result.contains("World"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("" decodes to double quote")
|
||||||
|
func quotEntity() {
|
||||||
|
#expect("Say "hi"".strippingHTML == "Say \"hi\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Edge cases
|
||||||
|
|
||||||
|
@Test("Empty string returns empty string")
|
||||||
|
func emptyString() {
|
||||||
|
#expect("".strippingHTML == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Plain text without HTML is returned unchanged")
|
||||||
|
func plainText() {
|
||||||
|
#expect("No tags here".strippingHTML == "No tags here")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leading and trailing whitespace is trimmed")
|
||||||
|
func trimmingWhitespace() {
|
||||||
|
#expect("<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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user