restructure
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
This commit is contained in:
583
iOS/RSSuper.xcodeproj/project.pbxproj
Normal file
583
iOS/RSSuper.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,583 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
27A094F62F79600D0067CFA4 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 27A094DE2F79600B0067CFA4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 27A094E52F79600B0067CFA4;
|
||||
remoteInfo = RSSuper;
|
||||
};
|
||||
27A095002F79600D0067CFA4 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 27A094DE2F79600B0067CFA4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 27A094E52F79600B0067CFA4;
|
||||
remoteInfo = RSSuper;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
27A094E62F79600B0067CFA4 /* RSSuper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RSSuper.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
27A094F52F79600D0067CFA4 /* RSSuperTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RSSuperTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
27A094FF2F79600D0067CFA4 /* RSSuperUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RSSuperUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
27A094E82F79600B0067CFA4 /* RSSuper */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = RSSuper;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
27A094F82F79600D0067CFA4 /* RSSuperTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = RSSuperTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
27A095022F79600D0067CFA4 /* RSSuperUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = RSSuperUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
27A094E32F79600B0067CFA4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094F22F79600D0067CFA4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094FC2F79600D0067CFA4 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
27A094DD2F79600B0067CFA4 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
27A094E82F79600B0067CFA4 /* RSSuper */,
|
||||
27A094F82F79600D0067CFA4 /* RSSuperTests */,
|
||||
27A095022F79600D0067CFA4 /* RSSuperUITests */,
|
||||
27A094E72F79600B0067CFA4 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
27A094E72F79600B0067CFA4 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
27A094E62F79600B0067CFA4 /* RSSuper.app */,
|
||||
27A094F52F79600D0067CFA4 /* RSSuperTests.xctest */,
|
||||
27A094FF2F79600D0067CFA4 /* RSSuperUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
27A094E52F79600B0067CFA4 /* RSSuper */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 27A095092F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuper" */;
|
||||
buildPhases = (
|
||||
27A094E22F79600B0067CFA4 /* Sources */,
|
||||
27A094E32F79600B0067CFA4 /* Frameworks */,
|
||||
27A094E42F79600B0067CFA4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
27A094E82F79600B0067CFA4 /* RSSuper */,
|
||||
);
|
||||
name = RSSuper;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = RSSuper;
|
||||
productReference = 27A094E62F79600B0067CFA4 /* RSSuper.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
27A094F42F79600D0067CFA4 /* RSSuperTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 27A0950C2F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuperTests" */;
|
||||
buildPhases = (
|
||||
27A094F12F79600D0067CFA4 /* Sources */,
|
||||
27A094F22F79600D0067CFA4 /* Frameworks */,
|
||||
27A094F32F79600D0067CFA4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
27A094F72F79600D0067CFA4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
27A094F82F79600D0067CFA4 /* RSSuperTests */,
|
||||
);
|
||||
name = RSSuperTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = RSSuperTests;
|
||||
productReference = 27A094F52F79600D0067CFA4 /* RSSuperTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
27A094FE2F79600D0067CFA4 /* RSSuperUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 27A0950F2F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuperUITests" */;
|
||||
buildPhases = (
|
||||
27A094FB2F79600D0067CFA4 /* Sources */,
|
||||
27A094FC2F79600D0067CFA4 /* Frameworks */,
|
||||
27A094FD2F79600D0067CFA4 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
27A095012F79600D0067CFA4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
27A095022F79600D0067CFA4 /* RSSuperUITests */,
|
||||
);
|
||||
name = RSSuperUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = RSSuperUITests;
|
||||
productReference = 27A094FF2F79600D0067CFA4 /* RSSuperUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
27A094DE2F79600B0067CFA4 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
27A094E52F79600B0067CFA4 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
27A094F42F79600D0067CFA4 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 27A094E52F79600B0067CFA4;
|
||||
};
|
||||
27A094FE2F79600D0067CFA4 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 27A094E52F79600B0067CFA4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 27A094E12F79600B0067CFA4 /* Build configuration list for PBXProject "RSSuper" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 27A094DD2F79600B0067CFA4;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 27A094E72F79600B0067CFA4 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
27A094E52F79600B0067CFA4 /* RSSuper */,
|
||||
27A094F42F79600D0067CFA4 /* RSSuperTests */,
|
||||
27A094FE2F79600D0067CFA4 /* RSSuperUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
27A094E42F79600B0067CFA4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094F32F79600D0067CFA4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094FD2F79600D0067CFA4 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
27A094E22F79600B0067CFA4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094F12F79600D0067CFA4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
27A094FB2F79600D0067CFA4 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
27A094F72F79600D0067CFA4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 27A094E52F79600B0067CFA4 /* RSSuper */;
|
||||
targetProxy = 27A094F62F79600D0067CFA4 /* PBXContainerItemProxy */;
|
||||
};
|
||||
27A095012F79600D0067CFA4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 27A094E52F79600B0067CFA4 /* RSSuper */;
|
||||
targetProxy = 27A095002F79600D0067CFA4 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
27A095072F79600D0067CFA4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
27A095082F79600D0067CFA4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
27A0950A2F79600D0067CFA4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuper;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
27A0950B2F79600D0067CFA4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuper;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
27A0950D2F79600D0067CFA4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuperTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RSSuper.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RSSuper";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
27A0950E2F79600D0067CFA4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuperTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RSSuper.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RSSuper";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
27A095102F79600D0067CFA4 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuperUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = RSSuper;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
27A095112F79600D0067CFA4 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6GK4F9L62V;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mikefreno.RSSuperUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = RSSuper;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
27A094E12F79600B0067CFA4 /* Build configuration list for PBXProject "RSSuper" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
27A095072F79600D0067CFA4 /* Debug */,
|
||||
27A095082F79600D0067CFA4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
27A095092F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuper" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
27A0950A2F79600D0067CFA4 /* Debug */,
|
||||
27A0950B2F79600D0067CFA4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
27A0950C2F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuperTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
27A0950D2F79600D0067CFA4 /* Debug */,
|
||||
27A0950E2F79600D0067CFA4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
27A0950F2F79600D0067CFA4 /* Build configuration list for PBXNativeTarget "RSSuperUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
27A095102F79600D0067CFA4 /* Debug */,
|
||||
27A095112F79600D0067CFA4 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 27A094DE2F79600B0067CFA4 /* Project object */;
|
||||
}
|
||||
7
iOS/RSSuper.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/RSSuper.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
BIN
iOS/RSSuper.xcodeproj/project.xcworkspace/xcuserdata/mike.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
BIN
iOS/RSSuper.xcodeproj/project.xcworkspace/xcuserdata/mike.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>RSSuper.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
35
iOS/RSSuper/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
iOS/RSSuper/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
iOS/RSSuper/Assets.xcassets/Contents.json
Normal file
6
iOS/RSSuper/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
61
iOS/RSSuper/ContentView.swift
Normal file
61
iOS/RSSuper/ContentView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
764
iOS/RSSuper/Database/DatabaseManager.swift
Normal file
764
iOS/RSSuper/Database/DatabaseManager.swift
Normal file
@@ -0,0 +1,764 @@
|
||||
//
|
||||
// DatabaseManager.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
enum DatabaseError: LocalizedError {
|
||||
case objectNotFound
|
||||
case saveFailed(Error)
|
||||
case fetchFailed(Error)
|
||||
case migrationFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .objectNotFound:
|
||||
return "Object not found"
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save: \(error.localizedDescription)"
|
||||
case .fetchFailed(let error):
|
||||
return "Failed to fetch: \(error.localizedDescription)"
|
||||
case .migrationFailed(let error):
|
||||
return "Migration failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DatabaseManager {
|
||||
static let shared = DatabaseManager()
|
||||
|
||||
private var db: OpaquePointer?
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private init() {
|
||||
let dbPath = databasePath()
|
||||
ensureDatabaseDirectoryExists()
|
||||
|
||||
if sqlite3_open(dbPath, &db) != SQLITE_OK {
|
||||
fatalError("Failed to open database")
|
||||
}
|
||||
|
||||
enableForeignKeys()
|
||||
try? migrateDatabase()
|
||||
}
|
||||
|
||||
deinit {
|
||||
if db != nil {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
}
|
||||
|
||||
private func databasePath() -> String {
|
||||
let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
let documentsDirectory = urls[0]
|
||||
return documentsDirectory.appendingPathComponent("RSSuper.sqlite").path
|
||||
}
|
||||
|
||||
private func ensureDatabaseDirectoryExists() {
|
||||
let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
let documentsDirectory = urls[0]
|
||||
try? fileManager.createDirectory(at: documentsDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func enableForeignKeys() {
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
sqlite3_exec(db, "PRAGMA foreign_keys = ON", nil, nil, &errorMessage)
|
||||
}
|
||||
|
||||
private func migrateDatabase() throws {
|
||||
createSubscriptionsTable()
|
||||
createFeedItemsTable()
|
||||
createSearchHistoryTable()
|
||||
createFTSIndex()
|
||||
createIndexes()
|
||||
}
|
||||
|
||||
private func createSubscriptionsTable() {
|
||||
let createTableSQL = """
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
fetch_interval INTEGER NOT NULL DEFAULT 3600,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_fetched_at TEXT,
|
||||
next_fetch_at TEXT,
|
||||
error TEXT,
|
||||
http_auth BLOB
|
||||
)
|
||||
"""
|
||||
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
|
||||
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
|
||||
let error = String(cString: errorMessage!)
|
||||
fatalError("Failed to create subscriptions table: \(error)")
|
||||
}
|
||||
|
||||
let uniqueIndexSQL = "CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_url ON subscriptions(url)"
|
||||
var error: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(error) }
|
||||
sqlite3_exec(db, uniqueIndexSQL, nil, nil, &error)
|
||||
}
|
||||
|
||||
private func createFeedItemsTable() {
|
||||
let createTableSQL = """
|
||||
CREATE TABLE IF NOT EXISTS feed_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
link TEXT,
|
||||
description TEXT,
|
||||
content TEXT,
|
||||
author TEXT,
|
||||
published TEXT,
|
||||
updated TEXT,
|
||||
categories TEXT,
|
||||
enclosure BLOB,
|
||||
guid TEXT,
|
||||
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
subscription_title TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
|
||||
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
|
||||
let error = String(cString: errorMessage!)
|
||||
fatalError("Failed to create feed_items table: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createSearchHistoryTable() {
|
||||
let createTableSQL = """
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
query TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
|
||||
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
|
||||
let error = String(cString: errorMessage!)
|
||||
fatalError("Failed to create search_history table: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createFTSIndex() {
|
||||
let createFTSQL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
author,
|
||||
content='feed_items',
|
||||
content_rowid='rowid'
|
||||
)
|
||||
"""
|
||||
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
sqlite3_exec(db, createFTSQL, nil, nil, &errorMessage)
|
||||
|
||||
let triggerInsertSQL = """
|
||||
CREATE TRIGGER IF NOT EXISTS feed_items_ai AFTER INSERT ON feed_items BEGIN
|
||||
INSERT INTO feed_items_fts(rowid, title, description, content, author)
|
||||
VALUES (new.rowid, new.title, new.description, new.content, new.author);
|
||||
END
|
||||
"""
|
||||
var error: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(error) }
|
||||
sqlite3_exec(db, triggerInsertSQL, nil, nil, &error)
|
||||
|
||||
let triggerDeleteSQL = """
|
||||
CREATE TRIGGER IF NOT EXISTS feed_items_ad AFTER DELETE ON feed_items BEGIN
|
||||
DELETE FROM feed_items_fts WHERE rowid = old.rowid;
|
||||
END
|
||||
"""
|
||||
defer { sqlite3_free(error) }
|
||||
sqlite3_exec(db, triggerDeleteSQL, nil, nil, &error)
|
||||
|
||||
let triggerUpdateSQL = """
|
||||
CREATE TRIGGER IF NOT EXISTS feed_items_au AFTER UPDATE ON feed_items BEGIN
|
||||
DELETE FROM feed_items_fts WHERE rowid = old.rowid;
|
||||
INSERT INTO feed_items_fts(rowid, title, description, content, author)
|
||||
VALUES (new.rowid, new.title, new.description, new.content, new.author);
|
||||
END
|
||||
"""
|
||||
defer { sqlite3_free(error) }
|
||||
sqlite3_exec(db, triggerUpdateSQL, nil, nil, &error)
|
||||
}
|
||||
|
||||
private func createIndexes() {
|
||||
let indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_feed_items_subscription_id ON feed_items(subscription_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_feed_items_published ON feed_items(published)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_feed_items_read ON feed_items(read)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_search_history_timestamp ON search_history(timestamp)"
|
||||
]
|
||||
|
||||
for indexSQL in indexes {
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
sqlite3_exec(db, indexSQL, nil, nil, &errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription CRUD
|
||||
|
||||
extension DatabaseManager {
|
||||
func createSubscription(id: String, url: String, title: String, category: String? = nil, enabled: Bool = true, fetchInterval: Int = 3600) throws -> FeedSubscription {
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
let subscription = FeedSubscription(
|
||||
id: id,
|
||||
url: url,
|
||||
title: title,
|
||||
category: category,
|
||||
enabled: enabled,
|
||||
fetchInterval: fetchInterval,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
lastFetchedAt: nil,
|
||||
nextFetchAt: nil,
|
||||
error: nil,
|
||||
httpAuth: nil
|
||||
)
|
||||
|
||||
let insertSQL = """
|
||||
INSERT INTO subscriptions (id, url, title, category, enabled, fetch_interval, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
guard let statement = prepareStatement(sql: insertSQL) else {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 2, (url as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 3, (title as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 4, (category as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_int(statement, 5, enabled ? 1 : 0)
|
||||
sqlite3_bind_int(statement, 6, Int32(fetchInterval))
|
||||
sqlite3_bind_text(statement, 7, (now as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 8, (now as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
func fetchSubscription(id: String) throws -> FeedSubscription? {
|
||||
let selectSQL = "SELECT * FROM subscriptions WHERE id = ? LIMIT 1"
|
||||
|
||||
guard let statement = prepareStatement(sql: selectSQL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) == SQLITE_ROW {
|
||||
return rowToSubscription(statement)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchAllSubscriptions() throws -> [FeedSubscription] {
|
||||
let selectSQL = "SELECT * FROM subscriptions ORDER BY created_at DESC"
|
||||
return executeQuery(sql: selectSQL, bindParams: [], rowMapper: rowToSubscription)
|
||||
}
|
||||
|
||||
func fetchEnabledSubscriptions() throws -> [FeedSubscription] {
|
||||
let selectSQL = "SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title"
|
||||
return executeQuery(sql: selectSQL, bindParams: [], rowMapper: rowToSubscription)
|
||||
}
|
||||
|
||||
func updateSubscription(_ subscription: FeedSubscription, title: String? = nil, category: String? = nil, enabled: Bool? = nil, fetchInterval: Int? = nil) throws -> FeedSubscription {
|
||||
var updated = subscription
|
||||
if let title = title { updated.title = title }
|
||||
if let category = category { updated.category = category }
|
||||
if let enabled = enabled { updated.enabled = enabled }
|
||||
if let fetchInterval = fetchInterval { updated.fetchInterval = fetchInterval }
|
||||
updated.updatedAt = Date()
|
||||
|
||||
let updateSQL = """
|
||||
UPDATE subscriptions SET
|
||||
title = ?, category = ?, enabled = ?, fetch_interval = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"""
|
||||
|
||||
guard let statement = prepareStatement(sql: updateSQL) else {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
sqlite3_bind_text(statement, 1, (updated.title as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 2, (updated.category as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_int(statement, 3, updated.enabled ? 1 : 0)
|
||||
sqlite3_bind_int(statement, 4, Int32(updated.fetchInterval))
|
||||
sqlite3_bind_text(statement, 5, (now as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 6, (updated.id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
func deleteSubscription(id: String) throws {
|
||||
let deleteSQL = "DELETE FROM subscriptions WHERE id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: deleteSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
private func rowToSubscription(_ statement: OpaquePointer) -> FeedSubscription {
|
||||
let id = String(cString: sqlite3_column_text(statement, 0))
|
||||
let url = String(cString: sqlite3_column_text(statement, 1))
|
||||
let title = String(cString: sqlite3_column_text(statement, 2))
|
||||
let category = sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 3))
|
||||
let enabled = sqlite3_column_int(statement, 4) == 1
|
||||
let fetchInterval = Int(sqlite3_column_int(statement, 5))
|
||||
let createdAt = parseDate(String(cString: sqlite3_column_text(statement, 6)))
|
||||
let updatedAt = parseDate(String(cString: sqlite3_column_text(statement, 7)))
|
||||
let lastFetchedAt = sqlite3_column_type(statement, 8) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 8)))
|
||||
let nextFetchAt = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 9)))
|
||||
let error = sqlite3_column_type(statement, 10) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 10))
|
||||
let httpAuthData = sqlite3_column_type(statement, 11) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 11), count: Int(sqlite3_column_bytes(statement, 11)))
|
||||
let httpAuth = httpAuthData.flatMap { try? JSONDecoder().decode(HttpAuth.self, from: $0) }
|
||||
|
||||
return FeedSubscription(
|
||||
id: id,
|
||||
url: url,
|
||||
title: title,
|
||||
category: category,
|
||||
enabled: enabled,
|
||||
fetchInterval: fetchInterval,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
lastFetchedAt: lastFetchedAt,
|
||||
nextFetchAt: nextFetchAt,
|
||||
error: error,
|
||||
httpAuth: httpAuth
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FeedItem CRUD
|
||||
|
||||
extension DatabaseManager {
|
||||
func createFeedItem(_ item: FeedItem) throws -> FeedItem {
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
|
||||
let insertSQL = """
|
||||
INSERT INTO feed_items (id, title, link, description, content, author, published, updated, categories, enclosure, guid, subscription_id, subscription_title, read, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
guard let statement = prepareStatement(sql: insertSQL) else {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
sqlite3_bind_text(statement, 1, (item.id as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 2, (item.title as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 3, (item.link as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 4, (item.description as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 5, (item.content as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 6, (item.author as NSString?)?.utf8String, -1, nil)
|
||||
if let published = item.published {
|
||||
sqlite3_bind_text(statement, 7, (ISO8601DateFormatter().string(from: published) as NSString).utf8String, -1, nil)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 7)
|
||||
}
|
||||
if let updated = item.updated {
|
||||
sqlite3_bind_text(statement, 8, (ISO8601DateFormatter().string(from: updated) as NSString).utf8String, -1, nil)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 8)
|
||||
}
|
||||
if let categories = item.categories, let data = try? JSONEncoder().encode(categories) {
|
||||
sqlite3_bind_blob(statement, 9, (data as NSData).bytes, Int32(data.count), nil)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 9)
|
||||
}
|
||||
if let enclosure = item.enclosure, let data = try? JSONEncoder().encode(enclosure) {
|
||||
sqlite3_bind_blob(statement, 10, (data as NSData).bytes, Int32(data.count), nil)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 10)
|
||||
}
|
||||
sqlite3_bind_text(statement, 11, (item.guid as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 12, (item.subscriptionId as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 13, (item.subscriptionTitle as NSString?)?.utf8String, -1, nil)
|
||||
sqlite3_bind_int(statement, 14, item.read ? 1 : 0)
|
||||
sqlite3_bind_text(statement, 15, (now as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func fetchFeedItem(id: String) throws -> FeedItem? {
|
||||
let selectSQL = "SELECT * FROM feed_items WHERE id = ? LIMIT 1"
|
||||
|
||||
guard let statement = prepareStatement(sql: selectSQL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) == SQLITE_ROW {
|
||||
return rowToFeedItem(statement)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchFeedItems(for subscriptionId: String) throws -> [FeedItem] {
|
||||
let selectSQL = "SELECT * FROM feed_items WHERE subscription_id = ? ORDER BY published DESC"
|
||||
return executeQuery(sql: selectSQL, bindParams: [subscriptionId], rowMapper: rowToFeedItem)
|
||||
}
|
||||
|
||||
func fetchFeedItems(limit: Int = 50) throws -> [FeedItem] {
|
||||
let selectSQL = "SELECT * FROM feed_items ORDER BY published DESC LIMIT ?"
|
||||
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
|
||||
}
|
||||
|
||||
func fetchUnreadFeedItems(limit: Int = 50) throws -> [FeedItem] {
|
||||
let selectSQL = "SELECT * FROM feed_items WHERE read = 0 ORDER BY published DESC LIMIT ?"
|
||||
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
|
||||
}
|
||||
|
||||
func updateFeedItem(_ item: FeedItem, read: Bool? = nil) throws -> FeedItem {
|
||||
guard let read = read else { return item }
|
||||
|
||||
let updateSQL = "UPDATE feed_items SET read = ? WHERE id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: updateSQL) else {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_int(statement, 1, read ? 1 : 0)
|
||||
sqlite3_bind_text(statement, 2, (item.id as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
var updatedItem = item
|
||||
updatedItem.read = read
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
func markAsRead(ids: [String]) throws {
|
||||
guard !ids.isEmpty else { return }
|
||||
|
||||
let placeholders = ids.map { _ in "?" }.joined(separator: ",")
|
||||
let updateSQL = "UPDATE feed_items SET read = 1 WHERE id IN (\(placeholders))"
|
||||
|
||||
guard let statement = prepareStatement(sql: updateSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
for (index, id) in ids.enumerated() {
|
||||
sqlite3_bind_text(statement, Int32(index + 1), (id as NSString).utf8String, -1, nil)
|
||||
}
|
||||
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
func deleteFeedItem(id: String) throws {
|
||||
let deleteSQL = "DELETE FROM feed_items WHERE id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: deleteSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
func deleteFeedItems(for subscriptionId: String) throws {
|
||||
let deleteSQL = "DELETE FROM feed_items WHERE subscription_id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: deleteSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (subscriptionId as NSString).utf8String, -1, nil)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
private func rowToFeedItem(_ statement: OpaquePointer) -> FeedItem {
|
||||
let id = String(cString: sqlite3_column_text(statement, 0))
|
||||
let title = String(cString: sqlite3_column_text(statement, 1))
|
||||
let link = sqlite3_column_type(statement, 2) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 2))
|
||||
let description = sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 3))
|
||||
let content = sqlite3_column_type(statement, 4) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 4))
|
||||
let author = sqlite3_column_type(statement, 5) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 5))
|
||||
let published = sqlite3_column_type(statement, 6) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 6)))
|
||||
let updated = sqlite3_column_type(statement, 7) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 7)))
|
||||
let categoriesData = sqlite3_column_type(statement, 8) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 8), count: Int(sqlite3_column_bytes(statement, 8)))
|
||||
let categories = categoriesData.flatMap { try? JSONDecoder().decode([String].self, from: $0) }
|
||||
let enclosureData = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 9), count: Int(sqlite3_column_bytes(statement, 9)))
|
||||
let enclosure = enclosureData.flatMap { try? JSONDecoder().decode(Enclosure.self, from: $0) }
|
||||
let guid = sqlite3_column_type(statement, 10) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 10))
|
||||
let subscriptionId = String(cString: sqlite3_column_text(statement, 11))
|
||||
let subscriptionTitle = sqlite3_column_type(statement, 12) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 12))
|
||||
let read = sqlite3_column_int(statement, 13) == 1
|
||||
|
||||
return FeedItem(
|
||||
id: id,
|
||||
title: title,
|
||||
link: link,
|
||||
description: description,
|
||||
content: content,
|
||||
author: author,
|
||||
published: published,
|
||||
updated: updated,
|
||||
categories: categories,
|
||||
enclosure: enclosure,
|
||||
guid: guid,
|
||||
subscriptionId: subscriptionId,
|
||||
subscriptionTitle: subscriptionTitle,
|
||||
read: read
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchHistory CRUD
|
||||
|
||||
extension DatabaseManager {
|
||||
func addToSearchHistory(query: String) throws -> SearchHistoryItem {
|
||||
let id = UUID().uuidString
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
let item = SearchHistoryItem(id: id, query: query, timestamp: Date())
|
||||
|
||||
let insertSQL = "INSERT INTO search_history (id, query, timestamp) VALUES (?, ?, ?)"
|
||||
|
||||
guard let statement = prepareStatement(sql: insertSQL) else {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 2, (query as NSString).utf8String, -1, nil)
|
||||
sqlite3_bind_text(statement, 3, (timestamp as NSString).utf8String, -1, nil)
|
||||
|
||||
if sqlite3_step(statement) != SQLITE_DONE {
|
||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func fetchSearchHistory(limit: Int = 20) throws -> [SearchHistoryItem] {
|
||||
let selectSQL = "SELECT * FROM search_history ORDER BY timestamp DESC LIMIT ?"
|
||||
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToSearchHistoryItem)
|
||||
}
|
||||
|
||||
func clearSearchHistory() throws {
|
||||
let deleteSQL = "DELETE FROM search_history"
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
defer { sqlite3_free(errorMessage) }
|
||||
sqlite3_exec(db, deleteSQL, nil, nil, &errorMessage)
|
||||
}
|
||||
|
||||
func deleteSearchHistoryItem(id: String) throws {
|
||||
let deleteSQL = "DELETE FROM search_history WHERE id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: deleteSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
private func rowToSearchHistoryItem(_ statement: OpaquePointer) -> SearchHistoryItem {
|
||||
let id = String(cString: sqlite3_column_text(statement, 0))
|
||||
let query = String(cString: sqlite3_column_text(statement, 1))
|
||||
let timestamp = parseDate(String(cString: sqlite3_column_text(statement, 2)))
|
||||
|
||||
return SearchHistoryItem(id: id, query: query, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FTS Search
|
||||
|
||||
extension DatabaseManager {
|
||||
func fullTextSearch(query: String, limit: Int = 50) throws -> [FeedItem] {
|
||||
let selectSQL = """
|
||||
SELECT * FROM feed_items
|
||||
WHERE title LIKE ? OR description LIKE ? OR content LIKE ? OR author LIKE ?
|
||||
ORDER BY published DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
let pattern = "%\(query)%"
|
||||
return executeQuery(sql: selectSQL, bindParams: [pattern, pattern, pattern, pattern, limit], rowMapper: rowToFeedItem)
|
||||
}
|
||||
|
||||
func advancedSearch(title: String? = nil, author: String? = nil, subscriptionId: String? = nil, startDate: Date? = nil, endDate: Date? = nil, limit: Int = 50) throws -> [FeedItem] {
|
||||
var conditions: [String] = []
|
||||
var parameters: [String] = []
|
||||
|
||||
if let title = title {
|
||||
conditions.append("title LIKE ?")
|
||||
parameters.append("%\(title)%")
|
||||
}
|
||||
|
||||
if let author = author {
|
||||
conditions.append("author LIKE ?")
|
||||
parameters.append("%\(author)%")
|
||||
}
|
||||
|
||||
if let subscriptionId = subscriptionId {
|
||||
conditions.append("subscription_id = ?")
|
||||
parameters.append(subscriptionId)
|
||||
}
|
||||
|
||||
if let startDate = startDate {
|
||||
conditions.append("published >= ?")
|
||||
parameters.append(ISO8601DateFormatter().string(from: startDate))
|
||||
}
|
||||
|
||||
if let endDate = endDate {
|
||||
conditions.append("published <= ?")
|
||||
parameters.append(ISO8601DateFormatter().string(from: endDate))
|
||||
}
|
||||
|
||||
var sql = "SELECT * FROM feed_items"
|
||||
if !conditions.isEmpty {
|
||||
sql += " WHERE " + conditions.joined(separator: " AND ")
|
||||
}
|
||||
sql += " ORDER BY published DESC LIMIT ?"
|
||||
parameters.append(String(limit))
|
||||
|
||||
return executeQuery(sql: sql, bindParams: parameters, rowMapper: rowToFeedItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Operations
|
||||
|
||||
extension DatabaseManager {
|
||||
func markAllAsRead(for subscriptionId: String) throws {
|
||||
let updateSQL = "UPDATE feed_items SET read = 1 WHERE subscription_id = ?"
|
||||
|
||||
guard let statement = prepareStatement(sql: updateSQL) else {
|
||||
return
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_text(statement, 1, (subscriptionId as NSString).utf8String, -1, nil)
|
||||
sqlite3_step(statement)
|
||||
}
|
||||
|
||||
func cleanupOldItems(olderThan days: Int, for subscriptionId: String? = nil) throws -> Int {
|
||||
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
|
||||
let cutoffString = ISO8601DateFormatter().string(from: cutoffDate)
|
||||
|
||||
var sql = "DELETE FROM feed_items WHERE published < ?"
|
||||
var params: [String] = [cutoffString]
|
||||
|
||||
if let subscriptionId = subscriptionId {
|
||||
sql += " AND subscription_id = ?"
|
||||
params.append(subscriptionId)
|
||||
}
|
||||
|
||||
guard let statement = prepareStatement(sql: sql) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
for (index, param) in params.enumerated() {
|
||||
sqlite3_bind_text(statement, Int32(index + 1), (param as NSString).utf8String, -1, nil)
|
||||
}
|
||||
|
||||
sqlite3_step(statement)
|
||||
return Int(sqlite3_changes(db))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
extension DatabaseManager {
|
||||
private func prepareStatement(sql: String) -> OpaquePointer? {
|
||||
var statement: OpaquePointer?
|
||||
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
|
||||
return statement
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func executeQuery<T>(sql: String, bindParams: [Any], rowMapper: (OpaquePointer) -> T) -> [T] {
|
||||
var results: [T] = []
|
||||
|
||||
guard let statement = prepareStatement(sql: sql) else {
|
||||
return results
|
||||
}
|
||||
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
for (index, param) in bindParams.enumerated() {
|
||||
let paramIndex = Int32(index + 1)
|
||||
if let stringParam = param as? String {
|
||||
sqlite3_bind_text(statement, paramIndex, (stringParam as NSString).utf8String, -1, nil)
|
||||
} else if let intParam = param as? Int {
|
||||
sqlite3_bind_int(statement, paramIndex, Int32(intParam))
|
||||
}
|
||||
}
|
||||
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
results.append(rowMapper(statement))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private func parseDate(_ string: String) -> Date {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: string) ?? Date()
|
||||
}
|
||||
}
|
||||
18
iOS/RSSuper/Item.swift
Normal file
18
iOS/RSSuper/Item.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Item.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
66
iOS/RSSuper/Models/ContentType.swift
Normal file
66
iOS/RSSuper/Models/ContentType.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// ContentType.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ContentType: String, Codable, CaseIterable {
|
||||
case article
|
||||
case audio
|
||||
case video
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .article: return "Article"
|
||||
case .audio: return "Audio"
|
||||
case .video: return "Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme: String, Codable, CaseIterable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Privacy: String, Codable, CaseIterable {
|
||||
case `public`
|
||||
case `private`
|
||||
case anonymous
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .public: return "Public"
|
||||
case .private: return "Private"
|
||||
case .anonymous: return "Anonymous"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationType: String, Codable, CaseIterable {
|
||||
case newArticle = "NEW_ARTICLE"
|
||||
case episodeRelease = "EPISODE_RELEASE"
|
||||
case customAlert = "CUSTOM_ALERT"
|
||||
case upgradePromo = "UPGRADE_PROMO"
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .newArticle: return "New Article"
|
||||
case .episodeRelease: return "Episode Release"
|
||||
case .customAlert: return "Custom Alert"
|
||||
case .upgradePromo: return "Upgrade Promo"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
iOS/RSSuper/Models/Date+Extensions.swift
Normal file
48
iOS/RSSuper/Models/Date+Extensions.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Date+Extensions.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
/// Creates a Date from Unix timestamp in milliseconds
|
||||
init?(millisecondsSince1970: Int64) {
|
||||
let seconds = TimeInterval(millisecondsSince1970) / 1000.0
|
||||
self.init(timeIntervalSince1970: seconds)
|
||||
}
|
||||
|
||||
/// Returns the Unix timestamp in milliseconds
|
||||
var millisecondsSince1970: Int64 {
|
||||
Int64((timeIntervalSince1970 * 1000).rounded())
|
||||
}
|
||||
|
||||
/// Creates a Date from Unix timestamp in seconds
|
||||
init?(secondsSince1970: Int64) {
|
||||
self.init(timeIntervalSince1970: TimeInterval(secondsSince1970))
|
||||
}
|
||||
|
||||
/// Returns the Unix timestamp in seconds
|
||||
var secondsSince1970: Int64 {
|
||||
Int64(timeIntervalSince1970.rounded())
|
||||
}
|
||||
|
||||
/// ISO8601 formatted string
|
||||
var iso8601String: String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
/// Creates a Date from ISO8601 string
|
||||
init?(iso8601String: String) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let date = formatter.date(from: iso8601String) else {
|
||||
return nil
|
||||
}
|
||||
self = date
|
||||
}
|
||||
}
|
||||
85
iOS/RSSuper/Models/Feed.swift
Normal file
85
iOS/RSSuper/Models/Feed.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// Feed.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Feed: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
var link: String?
|
||||
var description: String?
|
||||
var subtitle: String?
|
||||
var language: String?
|
||||
var lastBuildDate: Date?
|
||||
var updated: Date?
|
||||
var generator: String?
|
||||
var ttl: Int?
|
||||
var items: [FeedItem]
|
||||
var rawUrl: String
|
||||
var lastFetchedAt: Date?
|
||||
var nextFetchAt: Date?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case link
|
||||
case description
|
||||
case subtitle
|
||||
case language
|
||||
case lastBuildDate = "last_build_date"
|
||||
case updated
|
||||
case generator
|
||||
case ttl
|
||||
case items
|
||||
case rawUrl = "raw_url"
|
||||
case lastFetchedAt = "last_fetched_at"
|
||||
case nextFetchAt = "next_fetch_at"
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
link: String? = nil,
|
||||
description: String? = nil,
|
||||
subtitle: String? = nil,
|
||||
language: String? = nil,
|
||||
lastBuildDate: Date? = nil,
|
||||
updated: Date? = nil,
|
||||
generator: String? = nil,
|
||||
ttl: Int? = nil,
|
||||
items: [FeedItem] = [],
|
||||
rawUrl: String,
|
||||
lastFetchedAt: Date? = nil,
|
||||
nextFetchAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.link = link
|
||||
self.description = description
|
||||
self.subtitle = subtitle
|
||||
self.language = language
|
||||
self.lastBuildDate = lastBuildDate
|
||||
self.updated = updated
|
||||
self.generator = generator
|
||||
self.ttl = ttl
|
||||
self.items = items
|
||||
self.rawUrl = rawUrl
|
||||
self.lastFetchedAt = lastFetchedAt
|
||||
self.nextFetchAt = nextFetchAt
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
Feed(
|
||||
id: \(id),
|
||||
title: \(title),
|
||||
items: \(items.count),
|
||||
lastFetched: \(lastFetchedAt?.formatted(date: .abbreviated, time: .shortened) ?? "N/A")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
109
iOS/RSSuper/Models/FeedItem.swift
Normal file
109
iOS/RSSuper/Models/FeedItem.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// FeedItem.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedItem: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
var link: String?
|
||||
var description: String?
|
||||
var content: String?
|
||||
var author: String?
|
||||
var published: Date?
|
||||
var updated: Date?
|
||||
var categories: [String]?
|
||||
var enclosure: Enclosure?
|
||||
var guid: String?
|
||||
var subscriptionId: String
|
||||
var subscriptionTitle: String?
|
||||
var read: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case link
|
||||
case description
|
||||
case content
|
||||
case author
|
||||
case published
|
||||
case updated
|
||||
case categories
|
||||
case enclosure
|
||||
case guid
|
||||
case subscriptionId = "subscription_id"
|
||||
case subscriptionTitle = "subscription_title"
|
||||
case read
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
link: String? = nil,
|
||||
description: String? = nil,
|
||||
content: String? = nil,
|
||||
author: String? = nil,
|
||||
published: Date? = nil,
|
||||
updated: Date? = nil,
|
||||
categories: [String]? = nil,
|
||||
enclosure: Enclosure? = nil,
|
||||
guid: String? = nil,
|
||||
subscriptionId: String,
|
||||
subscriptionTitle: String? = nil,
|
||||
read: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.link = link
|
||||
self.description = description
|
||||
self.content = content
|
||||
self.author = author
|
||||
self.published = published
|
||||
self.updated = updated
|
||||
self.categories = categories
|
||||
self.enclosure = enclosure
|
||||
self.guid = guid
|
||||
self.subscriptionId = subscriptionId
|
||||
self.subscriptionTitle = subscriptionTitle
|
||||
self.read = read
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
FeedItem(
|
||||
id: \(id),
|
||||
title: \(title),
|
||||
author: \(author ?? "N/A"),
|
||||
published: \(published?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
|
||||
subscription: \(subscriptionTitle ?? "N/A")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
struct Enclosure: Codable, Equatable {
|
||||
let url: String
|
||||
let type: String
|
||||
var length: Int64?
|
||||
|
||||
var mimeType: ContentType {
|
||||
if type.contains("audio") {
|
||||
return .audio
|
||||
} else if type.contains("video") {
|
||||
return .video
|
||||
} else {
|
||||
return .article
|
||||
}
|
||||
}
|
||||
|
||||
var fileSizeDescription: String {
|
||||
guard let length = length else { return "Unknown" }
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: length)
|
||||
}
|
||||
}
|
||||
89
iOS/RSSuper/Models/FeedSubscription.swift
Normal file
89
iOS/RSSuper/Models/FeedSubscription.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// FeedSubscription.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FeedSubscription: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let url: String
|
||||
var title: String
|
||||
var category: String?
|
||||
var enabled: Bool
|
||||
var fetchInterval: Int
|
||||
let createdAt: Date
|
||||
var updatedAt: Date
|
||||
var lastFetchedAt: Date?
|
||||
var nextFetchAt: Date?
|
||||
var error: String?
|
||||
var httpAuth: HttpAuth?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case title
|
||||
case category
|
||||
case enabled
|
||||
case fetchInterval = "fetch_interval"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case lastFetchedAt = "last_fetched_at"
|
||||
case nextFetchAt = "next_fetch_at"
|
||||
case error
|
||||
case httpAuth = "http_auth"
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
url: String,
|
||||
title: String,
|
||||
category: String? = nil,
|
||||
enabled: Bool = true,
|
||||
fetchInterval: Int = 60,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date = Date(),
|
||||
lastFetchedAt: Date? = nil,
|
||||
nextFetchAt: Date? = nil,
|
||||
error: String? = nil,
|
||||
httpAuth: HttpAuth? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.title = title
|
||||
self.category = category
|
||||
self.enabled = enabled
|
||||
self.fetchInterval = fetchInterval
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.lastFetchedAt = lastFetchedAt
|
||||
self.nextFetchAt = nextFetchAt
|
||||
self.error = error
|
||||
self.httpAuth = httpAuth
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
FeedSubscription(
|
||||
id: \(id),
|
||||
title: \(title),
|
||||
url: \(url),
|
||||
enabled: \(enabled),
|
||||
fetchInterval: \(fetchInterval)min,
|
||||
error: \(error ?? "None")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpAuth: Codable, Equatable {
|
||||
let username: String
|
||||
let password: String
|
||||
|
||||
init(username: String, password: String) {
|
||||
self.username = username
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
54
iOS/RSSuper/Models/NotificationPreferences.swift
Normal file
54
iOS/RSSuper/Models/NotificationPreferences.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// NotificationPreferences.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationPreferences: Codable, Equatable {
|
||||
var newArticles: Bool
|
||||
var episodeReleases: Bool
|
||||
var customAlerts: Bool
|
||||
var badgeCount: Bool
|
||||
var sound: Bool
|
||||
var vibration: Bool
|
||||
|
||||
init(
|
||||
newArticles: Bool = true,
|
||||
episodeReleases: Bool = true,
|
||||
customAlerts: Bool = false,
|
||||
badgeCount: Bool = true,
|
||||
sound: Bool = true,
|
||||
vibration: Bool = true
|
||||
) {
|
||||
self.newArticles = newArticles
|
||||
self.episodeReleases = episodeReleases
|
||||
self.customAlerts = customAlerts
|
||||
self.badgeCount = badgeCount
|
||||
self.sound = sound
|
||||
self.vibration = vibration
|
||||
}
|
||||
|
||||
var allEnabled: Bool {
|
||||
newArticles && episodeReleases && customAlerts && badgeCount && sound && vibration
|
||||
}
|
||||
|
||||
var anyEnabled: Bool {
|
||||
newArticles || episodeReleases || customAlerts || badgeCount || sound || vibration
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
NotificationPreferences(
|
||||
newArticles: \(newArticles),
|
||||
episodeReleases: \(episodeReleases),
|
||||
customAlerts: \(customAlerts),
|
||||
badgeCount: \(badgeCount),
|
||||
sound: \(sound),
|
||||
vibration: \(vibration)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
76
iOS/RSSuper/Models/ReadingPreferences.swift
Normal file
76
iOS/RSSuper/Models/ReadingPreferences.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// ReadingPreferences.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ReadingPreferences: Codable, Equatable {
|
||||
var fontSize: FontSize
|
||||
var lineHeight: LineHeight
|
||||
var showTableOfContents: Bool
|
||||
var showReadingTime: Bool
|
||||
var showAuthor: Bool
|
||||
var showDate: Bool
|
||||
|
||||
enum FontSize: String, Codable, CaseIterable {
|
||||
case small
|
||||
case medium
|
||||
case large
|
||||
case xlarge
|
||||
|
||||
var pointValue: CGFloat {
|
||||
switch self {
|
||||
case .small: return 14
|
||||
case .medium: return 16
|
||||
case .large: return 18
|
||||
case .xlarge: return 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LineHeight: String, Codable, CaseIterable {
|
||||
case normal
|
||||
case relaxed
|
||||
case loose
|
||||
|
||||
var multiplier: CGFloat {
|
||||
switch self {
|
||||
case .normal: return 1.2
|
||||
case .relaxed: return 1.5
|
||||
case .loose: return 1.8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
fontSize: FontSize = .medium,
|
||||
lineHeight: LineHeight = .relaxed,
|
||||
showTableOfContents: Bool = false,
|
||||
showReadingTime: Bool = true,
|
||||
showAuthor: Bool = true,
|
||||
showDate: Bool = true
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeight = lineHeight
|
||||
self.showTableOfContents = showTableOfContents
|
||||
self.showReadingTime = showReadingTime
|
||||
self.showAuthor = showAuthor
|
||||
self.showDate = showDate
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
ReadingPreferences(
|
||||
fontSize: \(fontSize.rawValue),
|
||||
lineHeight: \(lineHeight.rawValue),
|
||||
showTableOfContents: \(showTableOfContents),
|
||||
showReadingTime: \(showReadingTime),
|
||||
showAuthor: \(showAuthor),
|
||||
showDate: \(showDate)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
72
iOS/RSSuper/Models/SearchFilters.swift
Normal file
72
iOS/RSSuper/Models/SearchFilters.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// SearchFilters.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SearchFilters: Codable, Equatable {
|
||||
var dateFrom: Date?
|
||||
var dateTo: Date?
|
||||
var feedIds: [String]?
|
||||
var authors: [String]?
|
||||
var contentType: ContentType?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case dateFrom = "date_from"
|
||||
case dateTo = "date_to"
|
||||
case feedIds = "feed_ids"
|
||||
case authors
|
||||
case contentType = "content_type"
|
||||
}
|
||||
|
||||
init(
|
||||
dateFrom: Date? = nil,
|
||||
dateTo: Date? = nil,
|
||||
feedIds: [String]? = nil,
|
||||
authors: [String]? = nil,
|
||||
contentType: ContentType? = nil
|
||||
) {
|
||||
self.dateFrom = dateFrom
|
||||
self.dateTo = dateTo
|
||||
self.feedIds = feedIds
|
||||
self.authors = authors
|
||||
self.contentType = contentType
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
SearchFilters(
|
||||
dateFrom: \(dateFrom?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
|
||||
dateTo: \(dateTo?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
|
||||
feedIds: \(feedIds.map { "\($0.count) feeds" } ?? "All"),
|
||||
authors: \(authors.map { "\($0.count) authors" } ?? "All"),
|
||||
contentType: \(contentType?.localizedDescription ?? "All")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchSortOption: String, Codable, CaseIterable {
|
||||
case relevance = "relevance"
|
||||
case dateDesc = "date_desc"
|
||||
case dateAsc = "date_asc"
|
||||
case titleAsc = "title_asc"
|
||||
case titleDesc = "title_desc"
|
||||
case feedAsc = "feed_asc"
|
||||
case feedDesc = "feed_desc"
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .relevance: return "Relevance"
|
||||
case .dateDesc: return "Date (Newest First)"
|
||||
case .dateAsc: return "Date (Oldest First)"
|
||||
case .titleAsc: return "Title (A-Z)"
|
||||
case .titleDesc: return "Title (Z-A)"
|
||||
case .feedAsc: return "Feed (A-Z)"
|
||||
case .feedDesc: return "Feed (Z-A)"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
iOS/RSSuper/Models/SearchHistoryItem.swift
Normal file
30
iOS/RSSuper/Models/SearchHistoryItem.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// SearchHistoryItem.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SearchHistoryItem: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let query: String
|
||||
let timestamp: Date
|
||||
|
||||
init(id: String, query: String, timestamp: Date = Date()) {
|
||||
self.id = id
|
||||
self.query = query
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
SearchHistoryItem(
|
||||
id: \(id),
|
||||
query: \(query),
|
||||
timestamp: \(timestamp.formatted(date: .abbreviated, time: .shortened))
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
73
iOS/RSSuper/Models/SearchResult.swift
Normal file
73
iOS/RSSuper/Models/SearchResult.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// SearchResult.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SearchResult: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let type: SearchResultType
|
||||
let title: String
|
||||
var snippet: String?
|
||||
var link: String?
|
||||
var feedTitle: String?
|
||||
var published: Date?
|
||||
var score: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case snippet
|
||||
case link
|
||||
case feedTitle = "feed_title"
|
||||
case published
|
||||
case score
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
type: SearchResultType,
|
||||
title: String,
|
||||
snippet: String? = nil,
|
||||
link: String? = nil,
|
||||
feedTitle: String? = nil,
|
||||
published: Date? = nil,
|
||||
score: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.snippet = snippet
|
||||
self.link = link
|
||||
self.feedTitle = feedTitle
|
||||
self.published = published
|
||||
self.score = score
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"""
|
||||
SearchResult(
|
||||
id: \(id),
|
||||
type: \(type.localizedDescription),
|
||||
title: \(title),
|
||||
score: \(score.map { String($0) } ?? "N/A")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchResultType: String, Codable, CaseIterable {
|
||||
case article
|
||||
case feed
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .article: return "Article"
|
||||
case .feed: return "Feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
iOS/RSSuper/Networking/01_FetchResult.swift
Normal file
15
iOS/RSSuper/Networking/01_FetchResult.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct FetchResult {
|
||||
let feedData: Data
|
||||
let headers: [String: String]
|
||||
let url: URL
|
||||
let cached: Bool
|
||||
|
||||
init(feedData: Data, headers: [String: String], url: URL, cached: Bool = false) {
|
||||
self.feedData = feedData
|
||||
self.headers = headers
|
||||
self.url = url
|
||||
self.cached = cached
|
||||
}
|
||||
}
|
||||
172
iOS/RSSuper/Networking/03_FeedFetcher.swift
Normal file
172
iOS/RSSuper/Networking/03_FeedFetcher.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
final class FeedFetcher {
|
||||
private let session: URLSession
|
||||
private let cacheManager = CacheManager.shared
|
||||
private let timeout: TimeInterval = 15.0
|
||||
private let maxRetries = 3
|
||||
private let maxBackoffDelay: TimeInterval = 60.0
|
||||
|
||||
nonisolated init(session: URLSession = URLSession(configuration: .default)) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func fetchFeed(url: URL, credentials: HTTPAuthCredentials? = nil) async throws -> FetchResult {
|
||||
if let cached = cacheManager.getCachedResult(for: url), !isCacheExpired(for: url) {
|
||||
return cached
|
||||
}
|
||||
|
||||
return try await withRetries { retryNumber in
|
||||
try await self.fetchFeedInternal(url: url, credentials: credentials, retryNumber: retryNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchFeedInternal(url: URL, credentials: HTTPAuthCredentials?, retryNumber: Int) async throws -> FetchResult {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
if let credentials {
|
||||
request.setValue(credentials.authorizationHeader(), forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.noData
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
let headers = httpResponse.allHeaderFields.reduce(into: [String: String]()) { result, pair in
|
||||
if let key = pair.key as? String, let value = pair.value as? String {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
let result = FetchResult(
|
||||
feedData: data,
|
||||
headers: headers,
|
||||
url: url,
|
||||
cached: false
|
||||
)
|
||||
|
||||
if shouldCacheResponse(httpResponse: httpResponse) {
|
||||
cacheManager.cacheResult(result, for: url)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
case 401:
|
||||
throw NetworkError.authenticationError
|
||||
case 404:
|
||||
throw NetworkError.httpError(statusCode: 404)
|
||||
case 408:
|
||||
throw NetworkError.timeout
|
||||
case 500...599:
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
|
||||
default:
|
||||
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
private func withRetries<T>(_ operation: @escaping (Int) async throws -> T) async throws -> T {
|
||||
var lastError: Error?
|
||||
|
||||
for attempt in 0...maxRetries {
|
||||
if attempt > 0 {
|
||||
let delay = calculateBackoffDelay(attempt: attempt)
|
||||
try await Task.sleep(for: .seconds(delay))
|
||||
}
|
||||
|
||||
do {
|
||||
return try await operation(attempt)
|
||||
} catch {
|
||||
lastError = error
|
||||
let networkError = error as? NetworkError
|
||||
if !shouldRetry(error: networkError) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? NetworkError.noData
|
||||
}
|
||||
|
||||
private func shouldRetry(error: NetworkError?) -> Bool {
|
||||
guard let error else { return false }
|
||||
switch error {
|
||||
case .timeout, .httpError(_), .noData:
|
||||
return true
|
||||
case .invalidURL, .decodingError, .authenticationError, .cacheError:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateBackoffDelay(attempt: Int) -> TimeInterval {
|
||||
let baseDelay: TimeInterval = 0.5
|
||||
let delay = baseDelay * pow(2, Double(attempt))
|
||||
return min(delay, maxBackoffDelay)
|
||||
}
|
||||
|
||||
private func isCacheExpired(for url: URL) -> Bool {
|
||||
guard let cached = cacheManager.getCachedResult(for: url) else { return true }
|
||||
|
||||
if let cacheControl = cached.headers["Cache-Control"] ?? cached.headers["cache-control"] {
|
||||
if cacheControl.contains("no-store") {
|
||||
return true
|
||||
}
|
||||
if cacheControl.contains("no-cache") {
|
||||
return true
|
||||
}
|
||||
if let maxAge = extractMaxAge(from: cacheControl) {
|
||||
let age = Date().timeIntervalSince(cached.url.lastPathComponent.isEmpty ? Date() : Date())
|
||||
return age > maxAge
|
||||
}
|
||||
}
|
||||
|
||||
if let lastModified = cached.headers["Last-Modified"] ?? cached.headers["last-modified"],
|
||||
let cacheUntil = parseHTTPDate(lastModified) {
|
||||
return Date() > cacheUntil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldCacheResponse(httpResponse: HTTPURLResponse) -> Bool {
|
||||
guard let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String else {
|
||||
return httpResponse.statusCode == 200
|
||||
}
|
||||
|
||||
if cacheControl.contains("no-store") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func extractMaxAge(from cacheControl: String) -> TimeInterval? {
|
||||
let parts = cacheControl.split(separator: ",")
|
||||
for part in parts {
|
||||
let trimmed = part.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.starts(with: "max-age=") {
|
||||
let valueString = trimmed.dropFirst(8)
|
||||
return Double(String(valueString))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseHTTPDate(_ dateString: String) -> Date? {
|
||||
let formatter = HTTPDateFormatter.formatter
|
||||
return formatter.date(from: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
private class HTTPDateFormatter {
|
||||
static let formatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
df.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
return df
|
||||
}()
|
||||
}
|
||||
37
iOS/RSSuper/Networking/CacheManager.swift
Normal file
37
iOS/RSSuper/Networking/CacheManager.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
final class CacheManager {
|
||||
static let shared = CacheManager()
|
||||
|
||||
private var cache = [String: FetchResult]()
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
func getCachedResult(for url: URL) -> FetchResult? {
|
||||
let key = url.absoluteString
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return cache[key]
|
||||
}
|
||||
|
||||
func cacheResult(_ result: FetchResult, for url: URL) {
|
||||
let key = url.absoluteString
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
cache[key] = result
|
||||
}
|
||||
|
||||
func removeCachedResult(for url: URL) {
|
||||
let key = url.absoluteString
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
cache.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
cache.removeAll()
|
||||
}
|
||||
}
|
||||
20
iOS/RSSuper/Networking/HTTPAuthCredentials.swift
Normal file
20
iOS/RSSuper/Networking/HTTPAuthCredentials.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated(unsafe) struct HTTPAuthCredentials {
|
||||
let username: String
|
||||
let password: String
|
||||
|
||||
nonisolated init(username: String, password: String) {
|
||||
self.username = username
|
||||
self.password = password
|
||||
}
|
||||
|
||||
nonisolated func authorizationHeader() -> String {
|
||||
let credentials = "\(username):\(password)"
|
||||
guard let data = credentials.data(using: .utf8) else {
|
||||
return ""
|
||||
}
|
||||
let base64 = data.base64EncodedString()
|
||||
return "Basic \(base64)"
|
||||
}
|
||||
}
|
||||
32
iOS/RSSuper/Networking/NetworkError.swift
Normal file
32
iOS/RSSuper/Networking/NetworkError.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
enum NetworkError: Error {
|
||||
case invalidURL
|
||||
case timeout
|
||||
case httpError(statusCode: Int)
|
||||
case noData
|
||||
case decodingError(Error)
|
||||
case authenticationError
|
||||
case cacheError(Error)
|
||||
}
|
||||
|
||||
extension NetworkError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .timeout:
|
||||
return "Request timeout"
|
||||
case .httpError(let statusCode):
|
||||
return "HTTP error: \(statusCode)"
|
||||
case .noData:
|
||||
return "No data received"
|
||||
case .decodingError(let error):
|
||||
return "Decoding error: \(error.localizedDescription)"
|
||||
case .authenticationError:
|
||||
return "Authentication failed"
|
||||
case .cacheError(let error):
|
||||
return "Cache error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
100
iOS/RSSuper/Parsing/AtomParser.swift
Normal file
100
iOS/RSSuper/Parsing/AtomParser.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
final class AtomParser {
|
||||
nonisolated init() {}
|
||||
|
||||
nonisolated func parse(xml: String, sourceURL: String) throws -> Feed {
|
||||
let lowered = xml.lowercased()
|
||||
guard lowered.contains("<feed"), lowered.contains("</feed>") else {
|
||||
throw FeedParsingError.malformedXML
|
||||
}
|
||||
|
||||
guard let feedBlock = xmlFirstBlock("feed", in: xml) else {
|
||||
throw FeedParsingError.malformedXML
|
||||
}
|
||||
|
||||
let entryBlocks = xmlAllBlocks("entry", in: feedBlock)
|
||||
let feedWithoutEntries = feedBlock.replacingOccurrences(
|
||||
of: "(?is)<(?:\\w+:)?entry\\b[^>]*>.*?</(?:\\w+:)?entry>",
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
let feedTitle = xmlFirstTagValue("title", in: feedWithoutEntries)?.xmlNilIfEmpty ?? sourceURL
|
||||
let feedLink = selectAlternateLink(in: feedWithoutEntries)
|
||||
let items = entryBlocks.map { parseEntry(xml: $0, subscriptionId: sourceURL, subscriptionTitle: feedTitle) }
|
||||
|
||||
return Feed(
|
||||
id: sourceURL,
|
||||
title: feedTitle,
|
||||
link: feedLink,
|
||||
description: xmlFirstTagValue("summary", in: feedWithoutEntries)?.xmlNilIfEmpty,
|
||||
subtitle: xmlFirstTagValue("subtitle", in: feedWithoutEntries)?.xmlNilIfEmpty,
|
||||
updated: XMLDateParser.parse(xmlFirstTagValue("updated", in: feedWithoutEntries)),
|
||||
generator: xmlFirstTagValue("generator", in: feedWithoutEntries)?.xmlNilIfEmpty,
|
||||
items: items,
|
||||
rawUrl: sourceURL
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func parseEntry(xml: String, subscriptionId: String, subscriptionTitle: String) -> FeedItem {
|
||||
let title = xmlFirstTagValue("title", in: xml)?.xmlNilIfEmpty
|
||||
let link = selectAlternateLink(in: xml)
|
||||
let guid = xmlFirstTagValue("id", in: xml)?.xmlNilIfEmpty
|
||||
let resolvedId = guid ?? link ?? UUID().uuidString
|
||||
|
||||
return FeedItem(
|
||||
id: resolvedId,
|
||||
title: title ?? link ?? "(Untitled)",
|
||||
link: link,
|
||||
description: xmlFirstTagValue("summary", in: xml)?.xmlNilIfEmpty,
|
||||
content: xmlFirstTagValue("content", in: xml)?.xmlNilIfEmpty,
|
||||
author: xmlFirstTagValue("name", in: xml)?.xmlNilIfEmpty
|
||||
?? xmlFirstTagValue("author", in: xml)?.xmlNilIfEmpty,
|
||||
published: XMLDateParser.parse(xmlFirstTagValue("published", in: xml)),
|
||||
updated: XMLDateParser.parse(xmlFirstTagValue("updated", in: xml)),
|
||||
categories: parseCategories(in: xml),
|
||||
enclosure: parseEnclosure(in: xml),
|
||||
guid: guid,
|
||||
subscriptionId: subscriptionId,
|
||||
subscriptionTitle: subscriptionTitle
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func parseCategories(in xml: String) -> [String]? {
|
||||
var values = xmlAllTagValues("category", in: xml)
|
||||
let attributes = xmlAllTagAttributes("category", in: xml)
|
||||
|
||||
for attribute in attributes {
|
||||
if let term = attribute["term"]?.xmlNilIfEmpty {
|
||||
values.append(term)
|
||||
}
|
||||
}
|
||||
|
||||
return values.isEmpty ? nil : values
|
||||
}
|
||||
|
||||
nonisolated private func parseEnclosure(in xml: String) -> Enclosure? {
|
||||
let links = xmlAllTagAttributes("link", in: xml)
|
||||
guard let enclosure = links.first(where: { $0["rel"]?.lowercased() == "enclosure" }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let href = enclosure["href"]?.xmlNilIfEmpty else { return nil }
|
||||
let type = enclosure["type"]?.xmlNilIfEmpty ?? "application/octet-stream"
|
||||
return Enclosure(url: href, type: type, length: xmlInt64(enclosure["length"]))
|
||||
}
|
||||
|
||||
nonisolated private func selectAlternateLink(in xml: String) -> String? {
|
||||
let links = xmlAllTagAttributes("link", in: xml)
|
||||
|
||||
if let preferred = links.first(where: {
|
||||
let rel = $0["rel"]?.lowercased() ?? "alternate"
|
||||
return rel == "alternate"
|
||||
}) {
|
||||
return preferred["href"]?.xmlNilIfEmpty
|
||||
}
|
||||
|
||||
return links.first?["href"]?.xmlNilIfEmpty
|
||||
}
|
||||
}
|
||||
37
iOS/RSSuper/Parsing/FeedParser.swift
Normal file
37
iOS/RSSuper/Parsing/FeedParser.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
final class FeedParser {
|
||||
nonisolated init() {}
|
||||
|
||||
nonisolated func parse(data: Data, sourceURL: String) throws -> ParseResult {
|
||||
let xml = String(decoding: data, as: UTF8.self)
|
||||
let feedType = try detectFeedType(from: xml)
|
||||
|
||||
switch feedType {
|
||||
case .rss:
|
||||
let feed = try RSSParser().parse(xml: xml, sourceURL: sourceURL)
|
||||
return ParseResult(feedType: .rss, feed: feed)
|
||||
case .atom:
|
||||
let feed = try AtomParser().parse(xml: xml, sourceURL: sourceURL)
|
||||
return ParseResult(feedType: .atom, feed: feed)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func detectFeedType(from xml: String) throws -> FeedType {
|
||||
let lowered = xml.lowercased()
|
||||
|
||||
if lowered.contains("<rss") {
|
||||
return .rss
|
||||
}
|
||||
|
||||
if lowered.contains("<feed") {
|
||||
return .atom
|
||||
}
|
||||
|
||||
if lowered.contains("<") {
|
||||
throw FeedParsingError.malformedXML
|
||||
}
|
||||
|
||||
throw FeedParsingError.unsupportedFeedType
|
||||
}
|
||||
}
|
||||
6
iOS/RSSuper/Parsing/FeedType.swift
Normal file
6
iOS/RSSuper/Parsing/FeedType.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum FeedType: String, Equatable {
|
||||
case rss
|
||||
case atom
|
||||
}
|
||||
11
iOS/RSSuper/Parsing/ParseResult.swift
Normal file
11
iOS/RSSuper/Parsing/ParseResult.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
struct ParseResult: Equatable {
|
||||
let feedType: FeedType
|
||||
let feed: Feed
|
||||
}
|
||||
|
||||
enum FeedParsingError: Error, Equatable {
|
||||
case unsupportedFeedType
|
||||
case malformedXML
|
||||
}
|
||||
84
iOS/RSSuper/Parsing/RSSParser.swift
Normal file
84
iOS/RSSuper/Parsing/RSSParser.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
final class RSSParser {
|
||||
nonisolated init() {}
|
||||
|
||||
nonisolated func parse(xml: String, sourceURL: String) throws -> Feed {
|
||||
let lowered = xml.lowercased()
|
||||
guard lowered.contains("<rss"), lowered.contains("</rss>") else {
|
||||
throw FeedParsingError.malformedXML
|
||||
}
|
||||
|
||||
guard let channel = xmlFirstBlock("channel", in: xml) else {
|
||||
throw FeedParsingError.malformedXML
|
||||
}
|
||||
|
||||
let itemBlocks = xmlAllBlocks("item", in: channel)
|
||||
let channelWithoutItems = channel.replacingOccurrences(
|
||||
of: "(?is)<(?:\\w+:)?item\\b[^>]*>.*?</(?:\\w+:)?item>",
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
let title = xmlFirstTagValue("title", in: channelWithoutItems)?.xmlNilIfEmpty ?? sourceURL
|
||||
let feedDescription = xmlFirstTagValue("description", in: channelWithoutItems)?.xmlNilIfEmpty
|
||||
?? xmlFirstTagValue("summary", in: channelWithoutItems)?.xmlNilIfEmpty
|
||||
let subtitle = xmlFirstTagValue("subtitle", in: channelWithoutItems)?.xmlNilIfEmpty
|
||||
let items = itemBlocks.map { parseItem(xml: $0, subscriptionId: sourceURL, subscriptionTitle: title) }
|
||||
|
||||
return Feed(
|
||||
id: sourceURL,
|
||||
title: title,
|
||||
link: xmlFirstTagValue("link", in: channelWithoutItems)?.xmlNilIfEmpty,
|
||||
description: feedDescription,
|
||||
subtitle: subtitle,
|
||||
language: xmlFirstTagValue("language", in: channelWithoutItems)?.xmlNilIfEmpty,
|
||||
lastBuildDate: XMLDateParser.parse(xmlFirstTagValue("lastBuildDate", in: channelWithoutItems)),
|
||||
generator: xmlFirstTagValue("generator", in: channelWithoutItems)?.xmlNilIfEmpty,
|
||||
ttl: Int(xmlFirstTagValue("ttl", in: channelWithoutItems) ?? ""),
|
||||
items: items,
|
||||
rawUrl: sourceURL
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func parseItem(xml: String, subscriptionId: String, subscriptionTitle: String) -> FeedItem {
|
||||
let title = xmlFirstTagValue("title", in: xml)?.xmlNilIfEmpty
|
||||
let link = xmlFirstTagValue("link", in: xml)?.xmlNilIfEmpty
|
||||
let guid = xmlFirstTagValue("guid", in: xml)?.xmlNilIfEmpty
|
||||
let resolvedId = guid ?? link ?? UUID().uuidString
|
||||
|
||||
let enclosureAttributes = xmlAllTagAttributes("enclosure", in: xml).first
|
||||
let enclosure = makeEnclosure(attributes: enclosureAttributes)
|
||||
|
||||
let description = xmlFirstTagValue("description", in: xml)?.xmlNilIfEmpty
|
||||
?? xmlFirstTagValue("summary", in: xml)?.xmlNilIfEmpty
|
||||
|
||||
return FeedItem(
|
||||
id: resolvedId,
|
||||
title: title ?? link ?? "(Untitled)",
|
||||
link: link,
|
||||
description: description,
|
||||
content: xmlFirstTagValue("encoded", in: xml)?.xmlNilIfEmpty,
|
||||
author: xmlFirstTagValue("author", in: xml)?.xmlNilIfEmpty
|
||||
?? xmlFirstTagValue("creator", in: xml)?.xmlNilIfEmpty,
|
||||
published: XMLDateParser.parse(xmlFirstTagValue("pubDate", in: xml)),
|
||||
categories: xmlAllTagValues("category", in: xml).isEmpty ? nil : xmlAllTagValues("category", in: xml),
|
||||
enclosure: enclosure,
|
||||
guid: guid,
|
||||
subscriptionId: subscriptionId,
|
||||
subscriptionTitle: subscriptionTitle
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func makeEnclosure(attributes: [String: String]?) -> Enclosure? {
|
||||
guard
|
||||
let attributes,
|
||||
let url = attributes["url"]?.xmlNilIfEmpty,
|
||||
let type = attributes["type"]?.xmlNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Enclosure(url: url, type: type, length: xmlInt64(attributes["length"]))
|
||||
}
|
||||
}
|
||||
145
iOS/RSSuper/Parsing/XMLParsingUtilities.swift
Normal file
145
iOS/RSSuper/Parsing/XMLParsingUtilities.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
struct XMLDateParser {
|
||||
nonisolated(unsafe) private static let iso8601WithFractional: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
nonisolated(unsafe) private static let iso8601: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
nonisolated(unsafe) private static let dateFormatters: [DateFormatter] = {
|
||||
let formats = [
|
||||
"EEE, dd MMM yyyy HH:mm:ss Z",
|
||||
"EEE, dd MMM yyyy HH:mm Z",
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"yyyy-MM-dd HH:mm:ss Z"
|
||||
]
|
||||
|
||||
return formats.map { format in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = format
|
||||
return formatter
|
||||
}
|
||||
}()
|
||||
|
||||
nonisolated(unsafe) static func parse(_ value: String?) -> Date? {
|
||||
guard let raw = value?.xmlTrimmed, !raw.isEmpty else { return nil }
|
||||
|
||||
if let date = iso8601WithFractional.date(from: raw) { return date }
|
||||
if let date = iso8601.date(from: raw) { return date }
|
||||
|
||||
for formatter in dateFormatters {
|
||||
if let date = formatter.date(from: raw) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
nonisolated(unsafe) var xmlTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) var xmlNilIfEmpty: String? {
|
||||
let trimmed = xmlTrimmed
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
nonisolated(unsafe) var xmlDecoded: String {
|
||||
replacingOccurrences(of: "<![CDATA[", with: "")
|
||||
.replacingOccurrences(of: "]]>", with: "")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlInt64(_ value: String?) -> Int64? {
|
||||
guard let value = value?.xmlTrimmed, !value.isEmpty else { return nil }
|
||||
return Int64(value)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlFirstTagValue(_ tag: String, in xml: String) -> String? {
|
||||
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
|
||||
guard let value = xmlFirstCapture(pattern: pattern, in: xml) else { return nil }
|
||||
return value.xmlDecoded.xmlTrimmed
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlAllTagValues(_ tag: String, in xml: String) -> [String] {
|
||||
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
|
||||
return xmlAllCaptures(pattern: pattern, in: xml)
|
||||
.map { $0.xmlDecoded.xmlTrimmed }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlFirstBlock(_ tag: String, in xml: String) -> String? {
|
||||
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
|
||||
return xmlFirstCapture(pattern: pattern, in: xml)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlAllBlocks(_ tag: String, in xml: String) -> [String] {
|
||||
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
|
||||
return xmlAllCaptures(pattern: pattern, in: xml)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) func xmlAllTagAttributes(_ tag: String, in xml: String) -> [[String: String]] {
|
||||
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b([^>]*)/?>"
|
||||
return xmlAllCaptures(pattern: pattern, in: xml).map(parseXMLAttributes)
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private func xmlFirstCapture(pattern: String, in text: String) -> String? {
|
||||
guard
|
||||
let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
|
||||
let range = Range(match.range(at: 1), in: text)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(text[range])
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private func xmlAllCaptures(pattern: String, in text: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||
|
||||
return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
|
||||
guard let range = Range(match.range(at: 1), in: text) else { return nil }
|
||||
return String(text[range])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated(unsafe) private func parseXMLAttributes(_ raw: String) -> [String: String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: "(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"") else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var result: [String: String] = [:]
|
||||
for match in regex.matches(in: raw, range: NSRange(raw.startIndex..., in: raw)) {
|
||||
guard
|
||||
let keyRange = Range(match.range(at: 1), in: raw),
|
||||
let valueRange = Range(match.range(at: 2), in: raw)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let key = String(raw[keyRange]).lowercased()
|
||||
let value = String(raw[valueRange]).xmlDecoded.xmlTrimmed
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
32
iOS/RSSuper/RSSuperApp.swift
Normal file
32
iOS/RSSuper/RSSuperApp.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// RSSuperApp.swift
|
||||
// RSSuper
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct RSSuperApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
395
iOS/RSSuperTests/DatabaseManagerTests.swift
Normal file
395
iOS/RSSuperTests/DatabaseManagerTests.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
//
|
||||
// DatabaseManagerTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class DatabaseManagerTests: XCTestCase {
|
||||
|
||||
private var databaseManager: DatabaseManager!
|
||||
private var testSubscriptionId: String!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
databaseManager = DatabaseManager.shared
|
||||
testSubscriptionId = UUID().uuidString
|
||||
|
||||
// Clean up any existing test data
|
||||
try? databaseManager.deleteSubscription(id: testSubscriptionId)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up test data
|
||||
try? databaseManager.deleteSubscription(id: testSubscriptionId)
|
||||
databaseManager = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Subscription CRUD Tests
|
||||
|
||||
func testCreateSubscription() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Subscription",
|
||||
category: "Technology",
|
||||
enabled: true,
|
||||
fetchInterval: 3600
|
||||
)
|
||||
|
||||
XCTAssertEqual(subscription.id, testSubscriptionId)
|
||||
XCTAssertEqual(subscription.url, "https://example.com/feed.xml")
|
||||
XCTAssertEqual(subscription.title, "Test Subscription")
|
||||
XCTAssertEqual(subscription.category, "Technology")
|
||||
XCTAssertTrue(subscription.enabled)
|
||||
XCTAssertEqual(subscription.fetchInterval, 3600)
|
||||
}
|
||||
|
||||
func testFetchSubscription() throws {
|
||||
// Create first
|
||||
_ = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Subscription"
|
||||
)
|
||||
|
||||
// Fetch
|
||||
let fetched = try databaseManager.fetchSubscription(id: testSubscriptionId)
|
||||
|
||||
XCTAssertNotNil(fetched)
|
||||
XCTAssertEqual(fetched?.id, testSubscriptionId)
|
||||
XCTAssertEqual(fetched?.title, "Test Subscription")
|
||||
}
|
||||
|
||||
func testFetchSubscriptionNotFound() throws {
|
||||
let fetched = try databaseManager.fetchSubscription(id: "non-existent-id")
|
||||
XCTAssertNil(fetched)
|
||||
}
|
||||
|
||||
func testFetchAllSubscriptions() throws {
|
||||
let id1 = UUID().uuidString
|
||||
let id2 = UUID().uuidString
|
||||
|
||||
try databaseManager.createSubscription(id: id1, url: "https://example1.com", title: "Sub 1")
|
||||
try databaseManager.createSubscription(id: id2, url: "https://example2.com", title: "Sub 2")
|
||||
|
||||
let subscriptions = try databaseManager.fetchAllSubscriptions()
|
||||
|
||||
XCTAssertGreaterThanOrEqual(subscriptions.count, 2)
|
||||
|
||||
// Cleanup
|
||||
try databaseManager.deleteSubscription(id: id1)
|
||||
try databaseManager.deleteSubscription(id: id2)
|
||||
}
|
||||
|
||||
func testFetchEnabledSubscriptions() throws {
|
||||
let id1 = UUID().uuidString
|
||||
let id2 = UUID().uuidString
|
||||
|
||||
try databaseManager.createSubscription(id: id1, url: "https://example1.com", title: "Sub 1", enabled: true)
|
||||
try databaseManager.createSubscription(id: id2, url: "https://example2.com", title: "Sub 2", enabled: false)
|
||||
|
||||
let subscriptions = try databaseManager.fetchEnabledSubscriptions()
|
||||
|
||||
XCTAssertTrue(subscriptions.allSatisfy { $0.enabled })
|
||||
|
||||
// Cleanup
|
||||
try databaseManager.deleteSubscription(id: id1)
|
||||
try databaseManager.deleteSubscription(id: id2)
|
||||
}
|
||||
|
||||
func testUpdateSubscription() throws {
|
||||
_ = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Original Title"
|
||||
)
|
||||
|
||||
let updated = try databaseManager.updateSubscription(
|
||||
try databaseManager.fetchSubscription(id: testSubscriptionId)!,
|
||||
title: "Updated Title",
|
||||
enabled: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(updated.title, "Updated Title")
|
||||
XCTAssertFalse(updated.enabled)
|
||||
}
|
||||
|
||||
func testDeleteSubscription() throws {
|
||||
_ = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "To Delete"
|
||||
)
|
||||
|
||||
try databaseManager.deleteSubscription(id: testSubscriptionId)
|
||||
|
||||
let fetched = try databaseManager.fetchSubscription(id: testSubscriptionId)
|
||||
XCTAssertNil(fetched)
|
||||
}
|
||||
|
||||
// MARK: - FeedItem CRUD Tests
|
||||
|
||||
func testCreateFeedItem() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let item = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Test Article",
|
||||
link: "https://example.com/article",
|
||||
description: "Article description",
|
||||
content: "Full article content",
|
||||
author: "John Doe",
|
||||
published: Date(),
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionTitle: subscription.title
|
||||
)
|
||||
|
||||
let created = try databaseManager.createFeedItem(item)
|
||||
|
||||
XCTAssertEqual(created.id, item.id)
|
||||
XCTAssertEqual(created.title, "Test Article")
|
||||
XCTAssertEqual(created.subscriptionId, testSubscriptionId)
|
||||
}
|
||||
|
||||
func testFetchFeedItem() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let itemId = UUID().uuidString
|
||||
let item = FeedItem(
|
||||
id: itemId,
|
||||
title: "To Fetch",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
_ = try databaseManager.createFeedItem(item)
|
||||
|
||||
let fetched = try databaseManager.fetchFeedItem(id: itemId)
|
||||
|
||||
XCTAssertNotNil(fetched)
|
||||
XCTAssertEqual(fetched?.id, itemId)
|
||||
}
|
||||
|
||||
func testFetchFeedItemsForSubscription() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
for i in 1...3 {
|
||||
let item = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Article \(i)",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
_ = try databaseManager.createFeedItem(item)
|
||||
}
|
||||
|
||||
let items = try databaseManager.fetchFeedItems(for: testSubscriptionId)
|
||||
|
||||
XCTAssertEqual(items.count, 3)
|
||||
}
|
||||
|
||||
func testFetchUnreadFeedItems() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let readItem = FeedItem(id: UUID().uuidString, title: "Read", subscriptionId: subscription.id, read: true)
|
||||
let unreadItem = FeedItem(id: UUID().uuidString, title: "Unread", subscriptionId: subscription.id, read: false)
|
||||
|
||||
_ = try databaseManager.createFeedItem(readItem)
|
||||
_ = try databaseManager.createFeedItem(unreadItem)
|
||||
|
||||
let unreadItems = try databaseManager.fetchUnreadFeedItems()
|
||||
|
||||
XCTAssertTrue(unreadItems.allSatisfy { !$0.read })
|
||||
}
|
||||
|
||||
func testMarkAsRead() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let item1 = FeedItem(id: UUID().uuidString, title: "Item 1", subscriptionId: subscription.id, read: false)
|
||||
let item2 = FeedItem(id: UUID().uuidString, title: "Item 2", subscriptionId: subscription.id, read: false)
|
||||
|
||||
_ = try databaseManager.createFeedItem(item1)
|
||||
_ = try databaseManager.createFeedItem(item2)
|
||||
|
||||
try databaseManager.markAsRead(ids: [item1.id, item2.id])
|
||||
|
||||
let fetched1 = try databaseManager.fetchFeedItem(id: item1.id)
|
||||
let fetched2 = try databaseManager.fetchFeedItem(id: item2.id)
|
||||
|
||||
XCTAssertTrue(fetched1?.read ?? false)
|
||||
XCTAssertTrue(fetched2?.read ?? false)
|
||||
}
|
||||
|
||||
func testDeleteFeedItem() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let item = FeedItem(id: UUID().uuidString, title: "To Delete", subscriptionId: subscription.id)
|
||||
_ = try databaseManager.createFeedItem(item)
|
||||
|
||||
try databaseManager.deleteFeedItem(id: item.id)
|
||||
|
||||
let fetched = try databaseManager.fetchFeedItem(id: item.id)
|
||||
XCTAssertNil(fetched)
|
||||
}
|
||||
|
||||
// MARK: - SearchHistory Tests
|
||||
|
||||
func testAddToSearchHistory() throws {
|
||||
let item = try databaseManager.addToSearchHistory(query: "test query")
|
||||
|
||||
XCTAssertEqual(item.query, "test query")
|
||||
XCTAssertNotNil(item.id)
|
||||
}
|
||||
|
||||
func testFetchSearchHistory() throws {
|
||||
try databaseManager.addToSearchHistory(query: "Query 1")
|
||||
try databaseManager.addToSearchHistory(query: "Query 2")
|
||||
|
||||
let history = try databaseManager.fetchSearchHistory()
|
||||
|
||||
XCTAssertGreaterThanOrEqual(history.count, 2)
|
||||
}
|
||||
|
||||
func testClearSearchHistory() throws {
|
||||
try databaseManager.addToSearchHistory(query: "To Clear")
|
||||
try databaseManager.clearSearchHistory()
|
||||
|
||||
let history = try databaseManager.fetchSearchHistory()
|
||||
XCTAssertTrue(history.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - FTS Search Tests
|
||||
|
||||
func testFullTextSearch() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let searchableItem = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Unique Title for Search Test",
|
||||
description: "This has special content",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
let nonMatchingItem = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Completely Different",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
|
||||
_ = try databaseManager.createFeedItem(searchableItem)
|
||||
_ = try databaseManager.createFeedItem(nonMatchingItem)
|
||||
|
||||
let results = try databaseManager.fullTextSearch(query: "Unique")
|
||||
|
||||
XCTAssertTrue(results.contains { $0.id == searchableItem.id || $0.title.contains("Unique") })
|
||||
XCTAssertFalse(results.contains { $0.id == nonMatchingItem.id })
|
||||
}
|
||||
|
||||
func testAdvancedSearch() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let item1 = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Searchable Title",
|
||||
author: "Test Author",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
let item2 = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Different Title",
|
||||
author: "Other Author",
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
|
||||
_ = try databaseManager.createFeedItem(item1)
|
||||
_ = try databaseManager.createFeedItem(item2)
|
||||
|
||||
let results = try databaseManager.advancedSearch(title: "Searchable", author: "Test")
|
||||
|
||||
XCTAssertTrue(results.contains { $0.id == item1.id })
|
||||
XCTAssertFalse(results.contains { $0.id == item2.id })
|
||||
}
|
||||
|
||||
// MARK: - Batch Operations Tests
|
||||
|
||||
func testMarkAllAsRead() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
_ = try databaseManager.createFeedItem(FeedItem(id: UUID().uuidString, title: "Item 1", subscriptionId: subscription.id, read: false))
|
||||
_ = try databaseManager.createFeedItem(FeedItem(id: UUID().uuidString, title: "Item 2", subscriptionId: subscription.id, read: false))
|
||||
|
||||
try databaseManager.markAllAsRead(for: testSubscriptionId)
|
||||
|
||||
let items = try databaseManager.fetchFeedItems(for: testSubscriptionId)
|
||||
XCTAssertTrue(items.allSatisfy { $0.read })
|
||||
}
|
||||
|
||||
func testCleanupOldItems() throws {
|
||||
let subscription = try databaseManager.createSubscription(
|
||||
id: testSubscriptionId,
|
||||
url: "https://example.com",
|
||||
title: "Test Sub"
|
||||
)
|
||||
|
||||
let oldItem = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "Old Item",
|
||||
published: Calendar.current.date(byAdding: .day, value: -30, to: Date()),
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
let newItem = FeedItem(
|
||||
id: UUID().uuidString,
|
||||
title: "New Item",
|
||||
published: Date(),
|
||||
subscriptionId: subscription.id
|
||||
)
|
||||
|
||||
_ = try databaseManager.createFeedItem(oldItem)
|
||||
_ = try databaseManager.createFeedItem(newItem)
|
||||
|
||||
let deletedCount = try databaseManager.cleanupOldItems(olderThan: 7, for: testSubscriptionId)
|
||||
|
||||
XCTAssertEqual(deletedCount, 1)
|
||||
|
||||
let remainingItems = try databaseManager.fetchFeedItems(for: testSubscriptionId)
|
||||
XCTAssertEqual(remainingItems.count, 1)
|
||||
XCTAssertEqual(remainingItems.first?.id, newItem.id)
|
||||
}
|
||||
}
|
||||
56
iOS/RSSuperTests/DateExtensionsTests.swift
Normal file
56
iOS/RSSuperTests/DateExtensionsTests.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// DateExtensionsTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class DateExtensionsTests: XCTestCase {
|
||||
|
||||
func testMillisecondsSince1970() {
|
||||
let date = Date(timeIntervalSince1970: 1609459200)
|
||||
XCTAssertEqual(date.millisecondsSince1970, 1609459200000)
|
||||
}
|
||||
|
||||
func testInitFromMilliseconds() {
|
||||
let date = Date(millisecondsSince1970: 1609459200000)!
|
||||
XCTAssertEqual(date.timeIntervalSince1970, 1609459200)
|
||||
}
|
||||
|
||||
func testSecondsSince1970() {
|
||||
let date = Date(timeIntervalSince1970: 1609459200)
|
||||
XCTAssertEqual(date.secondsSince1970, 1609459200)
|
||||
}
|
||||
|
||||
func testInitFromSeconds() {
|
||||
let date = Date(secondsSince1970: 1609459200)!
|
||||
XCTAssertEqual(date.timeIntervalSince1970, 1609459200)
|
||||
}
|
||||
|
||||
func testISO8601String() {
|
||||
let date = Date(timeIntervalSince1970: 1609459200)
|
||||
let isoString = date.iso8601String
|
||||
|
||||
// Verify it's a valid ISO8601 string
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
XCTAssertNotNil(formatter.date(from: isoString))
|
||||
}
|
||||
|
||||
func testInitFromISO8601String() {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let isoString = formatter.string(from: Date(timeIntervalSince1970: 1609459200))
|
||||
|
||||
let date = Date(iso8601String: isoString)!
|
||||
XCTAssertEqual(date.timeIntervalSince1970, 1609459200, accuracy: 1.0)
|
||||
}
|
||||
|
||||
func testInvalidISO8601String() {
|
||||
let date = Date(iso8601String: "not-a-date")
|
||||
XCTAssertNil(date)
|
||||
}
|
||||
}
|
||||
217
iOS/RSSuperTests/FeedFetcherTests.swift
Normal file
217
iOS/RSSuperTests/FeedFetcherTests.swift
Normal file
@@ -0,0 +1,217 @@
|
||||
import XCTest
|
||||
import os.signpost
|
||||
@testable import RSSuper
|
||||
|
||||
final class FeedFetcherTests: XCTestCase {
|
||||
private var fetcher: FeedFetcher!
|
||||
private var session: MockURLSession!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
session = MockURLSession()
|
||||
fetcher = FeedFetcher(session: session)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
fetcher = nil
|
||||
session = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testFetchFeedSuccess() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
let feedData = Data(rssSample.utf8)
|
||||
|
||||
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
let result = try await fetcher.fetchFeed(url: url)
|
||||
|
||||
XCTAssertEqual(result.feedData, feedData)
|
||||
XCTAssertEqual(result.url, url)
|
||||
XCTAssertFalse(result.cached)
|
||||
}
|
||||
|
||||
func testFetchFeedWithAuthentication() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
let feedData = Data(rssSample.utf8)
|
||||
let credentials = HTTPAuthCredentials(username: "user", password: "pass")
|
||||
|
||||
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
let result = try await fetcher.fetchFeed(url: url, credentials: credentials)
|
||||
|
||||
XCTAssertEqual(result.feedData, feedData)
|
||||
let authHeader = session.lastAuthHeader
|
||||
XCTAssertTrue(authHeader?.starts(with: "Basic ") ?? false)
|
||||
}
|
||||
|
||||
func testFetchFeedTimeoutError() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
|
||||
session.error = URLError(.timedOut)
|
||||
|
||||
do {
|
||||
_ = try await fetcher.fetchFeed(url: url)
|
||||
XCTFail("Expected error to be thrown")
|
||||
} catch {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
|
||||
func testFetchFeed404Error() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
|
||||
session.response = (Data(), HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
do {
|
||||
_ = try await fetcher.fetchFeed(url: url)
|
||||
XCTFail("Expected error to be thrown")
|
||||
} catch {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
|
||||
func testFetchFeedAuthenticationError() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
|
||||
session.response = (Data(), HTTPURLResponse(url: url, statusCode: 401, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
do {
|
||||
_ = try await fetcher.fetchFeed(url: url)
|
||||
XCTFail("Expected error to be thrown")
|
||||
} catch {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
|
||||
func testFetchFeedRetriesOnTimeout() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
let feedData = Data(rssSample.utf8)
|
||||
|
||||
var callCount = 0
|
||||
session.onRequest = { [unowned self] in
|
||||
callCount += 1
|
||||
if callCount < 3 {
|
||||
self.session.error = URLError(.timedOut)
|
||||
}
|
||||
}
|
||||
|
||||
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
let result = try await fetcher.fetchFeed(url: url)
|
||||
|
||||
XCTAssertEqual(result.feedData, feedData)
|
||||
XCTAssertEqual(callCount, 3)
|
||||
}
|
||||
|
||||
func testFetchFeedCaching() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
let feedData = Data(rssSample.utf8)
|
||||
|
||||
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!)
|
||||
|
||||
let result1 = try await fetcher.fetchFeed(url: url)
|
||||
XCTAssertFalse(result1.cached)
|
||||
|
||||
let result2 = try await fetcher.fetchFeed(url: url)
|
||||
XCTAssertTrue(result2.cached)
|
||||
|
||||
XCTAssertEqual(result1.feedData, result2.feedData)
|
||||
}
|
||||
|
||||
func testFetchFeedRespectsCacheControlNoStore() async throws {
|
||||
let url = URL(string: "https://example.com/feed.xml")!
|
||||
let feedData = Data(rssSample.utf8)
|
||||
|
||||
session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Cache-Control": "no-store"])!)
|
||||
|
||||
let result1 = try await fetcher.fetchFeed(url: url)
|
||||
XCTAssertFalse(result1.cached)
|
||||
|
||||
let result2 = try await fetcher.fetchFeed(url: url)
|
||||
XCTAssertFalse(result2.cached)
|
||||
}
|
||||
|
||||
func testHTTPAuthAuthorizationHeader() {
|
||||
let credentials = HTTPAuthCredentials(username: "user", password: "pass")
|
||||
let authHeader = credentials.authorizationHeader()
|
||||
|
||||
XCTAssertEqual(authHeader, "Basic dXNlcjpwYXNz")
|
||||
}
|
||||
|
||||
func testHTTPAuthAuthorizationHeaderWithSpecialCharacters() {
|
||||
let credentials = HTTPAuthCredentials(username: "user@domain", password: "p@ss:w0rd")
|
||||
let authHeader = credentials.authorizationHeader()
|
||||
|
||||
let expectedData = "user@domain:p@ss:w0rd".data(using: .utf8)!
|
||||
let expectedBase64 = expectedData.base64EncodedString()
|
||||
XCTAssertEqual(authHeader, "Basic \(expectedBase64)")
|
||||
}
|
||||
}
|
||||
|
||||
private let rssSample = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A test feed</description>
|
||||
<item>
|
||||
<title>Test Item</title>
|
||||
<link>https://example.com/item1</link>
|
||||
<guid>item-1</guid>
|
||||
<description>Test item description</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
final class MockURLSession: URLSession {
|
||||
var response: (Data, URLResponse)?
|
||||
var error: Error?
|
||||
var onRequest: (() -> Void)?
|
||||
var lastAuthHeader: String?
|
||||
|
||||
private var requestCounter = 0
|
||||
|
||||
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
|
||||
requestCounter += 1
|
||||
|
||||
if let auth = request.allHTTPHeaderFields?["Authorization"] {
|
||||
lastAuthHeader = auth
|
||||
}
|
||||
|
||||
onRequest?()
|
||||
|
||||
if let error {
|
||||
completionHandler(nil, nil, error)
|
||||
return MockURLSessionDataTask(error: error)
|
||||
}
|
||||
|
||||
if let (data, response) = response {
|
||||
completionHandler(data, response, nil)
|
||||
return MockURLSessionDataTask()
|
||||
}
|
||||
|
||||
completionHandler(nil, nil, nil)
|
||||
return MockURLSessionDataTask()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
requestCounter = 0
|
||||
lastAuthHeader = nil
|
||||
}
|
||||
}
|
||||
|
||||
final class MockURLSessionDataTask: URLSessionDataTask {
|
||||
private let mockError: Error?
|
||||
|
||||
init(error: Error? = nil) {
|
||||
self.mockError = error
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func resume() {
|
||||
// No-op for testing
|
||||
}
|
||||
}
|
||||
148
iOS/RSSuperTests/FeedItemTests.swift
Normal file
148
iOS/RSSuperTests/FeedItemTests.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// FeedItemTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class FeedItemTests: XCTestCase {
|
||||
|
||||
func testFeedItemEncodingDecoding() throws {
|
||||
let item = FeedItem(
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
title: "Test Article",
|
||||
link: "https://example.com/article",
|
||||
description: "A test description",
|
||||
content: "Full content here",
|
||||
author: "John Doe",
|
||||
published: Date(millisecondsSince1970: 1609459200000),
|
||||
categories: ["tech", "swift"],
|
||||
enclosure: Enclosure(url: "https://example.com/audio.mp3", type: "audio/mpeg", length: 1234567),
|
||||
guid: "guid-123",
|
||||
subscriptionId: "sub-123",
|
||||
subscriptionTitle: "Test Feed"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(item)
|
||||
let decoded = try JSONDecoder().decode(FeedItem.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.id, item.id)
|
||||
XCTAssertEqual(decoded.title, item.title)
|
||||
XCTAssertEqual(decoded.link, item.link)
|
||||
XCTAssertEqual(decoded.description, item.description)
|
||||
XCTAssertEqual(decoded.content, item.content)
|
||||
XCTAssertEqual(decoded.author, item.author)
|
||||
XCTAssertEqual(decoded.published, item.published)
|
||||
XCTAssertEqual(decoded.categories, item.categories)
|
||||
XCTAssertEqual(decoded.guid, item.guid)
|
||||
XCTAssertEqual(decoded.subscriptionId, item.subscriptionId)
|
||||
XCTAssertEqual(decoded.subscriptionTitle, item.subscriptionTitle)
|
||||
}
|
||||
|
||||
func testFeedItemOptionalProperties() throws {
|
||||
let item = FeedItem(
|
||||
id: "test-id",
|
||||
title: "Minimal Item",
|
||||
subscriptionId: "sub-id"
|
||||
)
|
||||
|
||||
XCTAssertNil(item.link)
|
||||
XCTAssertNil(item.description)
|
||||
XCTAssertNil(item.content)
|
||||
XCTAssertNil(item.author)
|
||||
XCTAssertNil(item.published)
|
||||
XCTAssertNil(item.categories)
|
||||
XCTAssertNil(item.enclosure)
|
||||
XCTAssertNil(item.guid)
|
||||
XCTAssertNil(item.subscriptionTitle)
|
||||
|
||||
let data = try JSONEncoder().encode(item)
|
||||
let decoded = try JSONDecoder().decode(FeedItem.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.id, item.id)
|
||||
XCTAssertEqual(decoded.title, item.title)
|
||||
}
|
||||
|
||||
func testEnclosureEncodingDecoding() throws {
|
||||
let enclosure = Enclosure(
|
||||
url: "https://example.com/podcast.mp3",
|
||||
type: "audio/mpeg",
|
||||
length: 98765432
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(enclosure)
|
||||
let decoded = try JSONDecoder().decode(Enclosure.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.url, enclosure.url)
|
||||
XCTAssertEqual(decoded.type, enclosure.type)
|
||||
XCTAssertEqual(decoded.length, enclosure.length)
|
||||
}
|
||||
|
||||
func testEnclosureWithoutLength() throws {
|
||||
let enclosure = Enclosure(
|
||||
url: "https://example.com/video.mp4",
|
||||
type: "video/mp4"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(enclosure)
|
||||
let decoded = try JSONDecoder().decode(Enclosure.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.url, enclosure.url)
|
||||
XCTAssertEqual(decoded.type, enclosure.type)
|
||||
XCTAssertNil(decoded.length)
|
||||
}
|
||||
|
||||
func testFeedItemEquality() {
|
||||
let item1 = FeedItem(
|
||||
id: "same-id",
|
||||
title: "Same Title",
|
||||
subscriptionId: "sub-id"
|
||||
)
|
||||
|
||||
let item2 = FeedItem(
|
||||
id: "same-id",
|
||||
title: "Same Title",
|
||||
subscriptionId: "sub-id"
|
||||
)
|
||||
|
||||
let item3 = FeedItem(
|
||||
id: "different-id",
|
||||
title: "Different Title",
|
||||
subscriptionId: "sub-id"
|
||||
)
|
||||
|
||||
XCTAssertEqual(item1, item2)
|
||||
XCTAssertNotEqual(item1, item3)
|
||||
}
|
||||
|
||||
func testContentTypeDetection() {
|
||||
let audioEnclosure = Enclosure(url: "test.mp3", type: "audio/mpeg")
|
||||
XCTAssertEqual(audioEnclosure.mimeType, .audio)
|
||||
|
||||
let videoEnclosure = Enclosure(url: "test.mp4", type: "video/mp4")
|
||||
XCTAssertEqual(videoEnclosure.mimeType, .video)
|
||||
|
||||
let articleEnclosure = Enclosure(url: "test.pdf", type: "application/pdf")
|
||||
XCTAssertEqual(articleEnclosure.mimeType, .article)
|
||||
}
|
||||
|
||||
func testFeedItemDebugDescription() {
|
||||
let item = FeedItem(
|
||||
id: "test-id",
|
||||
title: "Debug Test",
|
||||
author: "Test Author",
|
||||
published: Date(millisecondsSince1970: 1609459200000),
|
||||
subscriptionId: "sub-id",
|
||||
subscriptionTitle: "My Feed"
|
||||
)
|
||||
|
||||
let debugDesc = item.debugDescription
|
||||
XCTAssertTrue(debugDesc.contains("test-id"))
|
||||
XCTAssertTrue(debugDesc.contains("Debug Test"))
|
||||
XCTAssertTrue(debugDesc.contains("Test Author"))
|
||||
XCTAssertTrue(debugDesc.contains("My Feed"))
|
||||
}
|
||||
}
|
||||
211
iOS/RSSuperTests/FeedParserTests.swift
Normal file
211
iOS/RSSuperTests/FeedParserTests.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class FeedParserTests: XCTestCase {
|
||||
func testParsesRSS20Feed() throws {
|
||||
let parser = FeedParser()
|
||||
let result = try parser.parse(
|
||||
data: Data(rssSample.utf8),
|
||||
sourceURL: "https://example.com/rss.xml"
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.feedType, .rss)
|
||||
XCTAssertEqual(result.feed.title, "Example Podcast")
|
||||
XCTAssertEqual(result.feed.subtitle, "Weekly iOS and Swift updates")
|
||||
XCTAssertEqual(result.feed.items.count, 2)
|
||||
XCTAssertEqual(result.feed.ttl, 60)
|
||||
|
||||
let firstItem = try XCTUnwrap(result.feed.items.first)
|
||||
XCTAssertEqual(firstItem.title, "Episode 1")
|
||||
XCTAssertEqual(firstItem.author, "Host Name")
|
||||
XCTAssertEqual(firstItem.guid, "episode-1")
|
||||
XCTAssertEqual(firstItem.categories, ["Swift"])
|
||||
XCTAssertEqual(firstItem.enclosure?.url, "https://example.com/audio/ep1.mp3")
|
||||
XCTAssertEqual(firstItem.enclosure?.type, "audio/mpeg")
|
||||
XCTAssertEqual(firstItem.enclosure?.length, 12345)
|
||||
XCTAssertEqual(firstItem.content, "<p>Full content for episode 1.</p>")
|
||||
}
|
||||
|
||||
func testParsesAtom10Feed() throws {
|
||||
let parser = FeedParser()
|
||||
let result = try parser.parse(
|
||||
data: Data(atomSample.utf8),
|
||||
sourceURL: "https://example.com/atom.xml"
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.feedType, .atom)
|
||||
XCTAssertEqual(result.feed.title, "Example Atom Feed")
|
||||
XCTAssertEqual(result.feed.subtitle, "Recent engineering posts")
|
||||
XCTAssertEqual(result.feed.link, "https://example.com")
|
||||
XCTAssertEqual(result.feed.items.count, 2)
|
||||
|
||||
let firstItem = try XCTUnwrap(result.feed.items.first)
|
||||
XCTAssertEqual(firstItem.guid, "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a")
|
||||
XCTAssertEqual(firstItem.link, "https://example.com/posts/1")
|
||||
XCTAssertEqual(firstItem.author, "Jane Author")
|
||||
XCTAssertEqual(firstItem.enclosure?.url, "https://example.com/audio/post1.mp3")
|
||||
XCTAssertEqual(firstItem.enclosure?.type, "audio/mpeg")
|
||||
XCTAssertEqual(firstItem.enclosure?.length, 2048)
|
||||
}
|
||||
|
||||
func testHandlesITunesNamespace() throws {
|
||||
let parser = FeedParser()
|
||||
let result = try parser.parse(
|
||||
data: Data(rssWithITunesSample.utf8),
|
||||
sourceURL: "https://example.com/itunes.xml"
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.feed.subtitle, "Podcast subtitle")
|
||||
XCTAssertEqual(result.feed.description, "Feed-level summary")
|
||||
|
||||
let item = try XCTUnwrap(result.feed.items.first)
|
||||
XCTAssertEqual(item.author, "Podcast Author")
|
||||
XCTAssertEqual(item.description, "Item-level summary")
|
||||
}
|
||||
|
||||
func testThrowsForMalformedXML() {
|
||||
let parser = FeedParser()
|
||||
XCTAssertThrowsError(
|
||||
try parser.parse(
|
||||
data: Data("<rss><channel><title>Broken".utf8),
|
||||
sourceURL: "https://example.com/broken.xml"
|
||||
)
|
||||
) { error in
|
||||
XCTAssertEqual(error as? FeedParsingError, .malformedXML)
|
||||
}
|
||||
}
|
||||
|
||||
func testParsesRealWorldStyleFeeds() throws {
|
||||
let parser = FeedParser()
|
||||
let rssResult = try parser.parse(
|
||||
data: Data(realWorldRSSSample.utf8),
|
||||
sourceURL: "https://feeds.example.com/news.xml"
|
||||
)
|
||||
XCTAssertEqual(rssResult.feedType, .rss)
|
||||
XCTAssertGreaterThan(rssResult.feed.items.count, 0)
|
||||
|
||||
let atomResult = try parser.parse(
|
||||
data: Data(realWorldAtomSample.utf8),
|
||||
sourceURL: "https://feeds.example.com/engineering.xml"
|
||||
)
|
||||
XCTAssertEqual(atomResult.feedType, .atom)
|
||||
XCTAssertGreaterThan(atomResult.feed.items.count, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private let rssSample = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>Example Podcast</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A sample RSS feed</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>Mon, 30 Mar 2026 10:00:00 +0000</lastBuildDate>
|
||||
<generator>RSSuper Test Suite</generator>
|
||||
<ttl>60</ttl>
|
||||
<itunes:subtitle>Weekly iOS and Swift updates</itunes:subtitle>
|
||||
<item>
|
||||
<title>Episode 1</title>
|
||||
<link>https://example.com/episodes/1</link>
|
||||
<guid>episode-1</guid>
|
||||
<pubDate>Mon, 30 Mar 2026 09:00:00 +0000</pubDate>
|
||||
<category>Swift</category>
|
||||
<description>Episode 1 summary</description>
|
||||
<content:encoded><![CDATA[<p>Full content for episode 1.</p>]]></content:encoded>
|
||||
<itunes:author>Host Name</itunes:author>
|
||||
<enclosure url="https://example.com/audio/ep1.mp3" type="audio/mpeg" length="12345" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode 2</title>
|
||||
<link>https://example.com/episodes/2</link>
|
||||
<guid>episode-2</guid>
|
||||
<pubDate>Mon, 30 Mar 2026 08:00:00 +0000</pubDate>
|
||||
<description>Episode 2 summary</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
private let atomSample = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Atom Feed</title>
|
||||
<subtitle>Recent engineering posts</subtitle>
|
||||
<link href="https://example.com" rel="alternate" />
|
||||
<updated>2026-03-30T10:00:00Z</updated>
|
||||
<generator>Atom Test Generator</generator>
|
||||
<entry>
|
||||
<title>Post One</title>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2026-03-30T09:00:00Z</updated>
|
||||
<published>2026-03-30T08:59:00Z</published>
|
||||
<summary>First post summary</summary>
|
||||
<content type="html"><p>First post full content</p></content>
|
||||
<author><name>Jane Author</name></author>
|
||||
<link href="https://example.com/posts/1" rel="alternate" />
|
||||
<link href="https://example.com/audio/post1.mp3" rel="enclosure" type="audio/mpeg" length="2048" />
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Post Two</title>
|
||||
<id>urn:uuid:7a9b2f0d-65b2-44a7-a2ad-d3c3ff7dd003</id>
|
||||
<updated>2026-03-30T08:00:00Z</updated>
|
||||
<summary>Second post summary</summary>
|
||||
<link href="https://example.com/posts/2" rel="alternate" />
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
|
||||
private let rssWithITunesSample = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>iTunes Feed</title>
|
||||
<itunes:subtitle>Podcast subtitle</itunes:subtitle>
|
||||
<itunes:summary>Feed-level summary</itunes:summary>
|
||||
<item>
|
||||
<title>Episode</title>
|
||||
<itunes:author>Podcast Author</itunes:author>
|
||||
<itunes:summary>Item-level summary</itunes:summary>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
private let realWorldRSSSample = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Daily Tech News</title>
|
||||
<link>https://news.example.com</link>
|
||||
<description>Latest updates from the tech world</description>
|
||||
<lastBuildDate>Mon, 30 Mar 2026 12:00:00 +0000</lastBuildDate>
|
||||
<item>
|
||||
<title>Apple ships new SDK tools</title>
|
||||
<link>https://news.example.com/apple-sdk-tools</link>
|
||||
<guid>news-1</guid>
|
||||
<pubDate>Mon, 30 Mar 2026 11:00:00 +0000</pubDate>
|
||||
<description>Tooling improvements for mobile developers.</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
private let realWorldAtomSample = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Engineering Blog</title>
|
||||
<updated>2026-03-30T12:00:00Z</updated>
|
||||
<link rel="alternate" href="https://engineering.example.com" />
|
||||
<entry>
|
||||
<title>Improving app startup performance</title>
|
||||
<id>tag:engineering.example.com,2026:post-1</id>
|
||||
<updated>2026-03-29T16:00:00Z</updated>
|
||||
<published>2026-03-29T15:00:00Z</published>
|
||||
<summary>How we reduced cold-start by 25%.</summary>
|
||||
<content>Detailed analysis of startup bottlenecks and fixes.</content>
|
||||
<author><name>Engineering Team</name></author>
|
||||
<link rel="alternate" href="https://engineering.example.com/posts/startup-performance" />
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
116
iOS/RSSuperTests/FeedSubscriptionTests.swift
Normal file
116
iOS/RSSuperTests/FeedSubscriptionTests.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// FeedSubscriptionTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class FeedSubscriptionTests: XCTestCase {
|
||||
|
||||
func testFeedSubscriptionEncodingDecoding() throws {
|
||||
let subscription = FeedSubscription(
|
||||
id: "sub-123",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Example Feed",
|
||||
category: "Tech",
|
||||
enabled: true,
|
||||
fetchInterval: 120,
|
||||
createdAt: Date(timeIntervalSince1970: 1609459200),
|
||||
updatedAt: Date(timeIntervalSince1970: 1609545600),
|
||||
lastFetchedAt: Date(timeIntervalSince1970: 1609632000),
|
||||
nextFetchAt: Date(timeIntervalSince1970: 1609718400),
|
||||
error: nil,
|
||||
httpAuth: HttpAuth(username: "user", password: "pass")
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(subscription)
|
||||
let decoded = try JSONDecoder().decode(FeedSubscription.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.id, subscription.id)
|
||||
XCTAssertEqual(decoded.url, subscription.url)
|
||||
XCTAssertEqual(decoded.title, subscription.title)
|
||||
XCTAssertEqual(decoded.category, subscription.category)
|
||||
XCTAssertEqual(decoded.enabled, subscription.enabled)
|
||||
XCTAssertEqual(decoded.fetchInterval, subscription.fetchInterval)
|
||||
XCTAssertEqual(decoded.createdAt, subscription.createdAt)
|
||||
XCTAssertEqual(decoded.updatedAt, subscription.updatedAt)
|
||||
XCTAssertEqual(decoded.lastFetchedAt, subscription.lastFetchedAt)
|
||||
XCTAssertEqual(decoded.nextFetchAt, subscription.nextFetchAt)
|
||||
XCTAssertEqual(decoded.httpAuth?.username, subscription.httpAuth?.username)
|
||||
XCTAssertEqual(decoded.httpAuth?.password, subscription.httpAuth?.password)
|
||||
}
|
||||
|
||||
func testFeedSubscriptionOptionalProperties() throws {
|
||||
let subscription = FeedSubscription(
|
||||
id: "minimal-sub",
|
||||
url: "https://example.com/minimal.xml",
|
||||
title: "Minimal Feed"
|
||||
)
|
||||
|
||||
XCTAssertNil(subscription.category)
|
||||
XCTAssertNil(subscription.lastFetchedAt)
|
||||
XCTAssertNil(subscription.nextFetchAt)
|
||||
XCTAssertNil(subscription.error)
|
||||
XCTAssertNil(subscription.httpAuth)
|
||||
|
||||
let data = try JSONEncoder().encode(subscription)
|
||||
let decoded = try JSONDecoder().decode(FeedSubscription.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.id, subscription.id)
|
||||
XCTAssertEqual(decoded.enabled, true)
|
||||
XCTAssertEqual(decoded.fetchInterval, 60)
|
||||
}
|
||||
|
||||
func testFeedSubscriptionEquality() {
|
||||
let sub1 = FeedSubscription(
|
||||
id: "same-id",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Same Title"
|
||||
)
|
||||
|
||||
let sub2 = FeedSubscription(
|
||||
id: "same-id",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Same Title"
|
||||
)
|
||||
|
||||
let sub3 = FeedSubscription(
|
||||
id: "different-id",
|
||||
url: "https://example.com/other.xml",
|
||||
title: "Different Title"
|
||||
)
|
||||
|
||||
XCTAssertEqual(sub1, sub2)
|
||||
XCTAssertNotEqual(sub1, sub3)
|
||||
}
|
||||
|
||||
func testHttpAuthEncodingDecoding() throws {
|
||||
let auth = HttpAuth(username: "testuser", password: "testpass")
|
||||
|
||||
let data = try JSONEncoder().encode(auth)
|
||||
let decoded = try JSONDecoder().decode(HttpAuth.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.username, auth.username)
|
||||
XCTAssertEqual(decoded.password, auth.password)
|
||||
}
|
||||
|
||||
func testFeedSubscriptionDebugDescription() {
|
||||
let subscription = FeedSubscription(
|
||||
id: "debug-id",
|
||||
url: "https://example.com/debug.xml",
|
||||
title: "Debug Feed",
|
||||
enabled: false,
|
||||
fetchInterval: 30,
|
||||
error: "Connection timeout"
|
||||
)
|
||||
|
||||
let debugDesc = subscription.debugDescription
|
||||
XCTAssertTrue(debugDesc.contains("debug-id"))
|
||||
XCTAssertTrue(debugDesc.contains("Debug Feed"))
|
||||
XCTAssertTrue(debugDesc.contains("30min"))
|
||||
XCTAssertTrue(debugDesc.contains("Connection timeout"))
|
||||
}
|
||||
}
|
||||
102
iOS/RSSuperTests/NotificationPreferencesTests.swift
Normal file
102
iOS/RSSuperTests/NotificationPreferencesTests.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// NotificationPreferencesTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class NotificationPreferencesTests: XCTestCase {
|
||||
|
||||
func testNotificationPreferencesEncodingDecoding() throws {
|
||||
let prefs = NotificationPreferences(
|
||||
newArticles: true,
|
||||
episodeReleases: false,
|
||||
customAlerts: true,
|
||||
badgeCount: true,
|
||||
sound: false,
|
||||
vibration: true
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(prefs)
|
||||
let decoded = try JSONDecoder().decode(NotificationPreferences.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.newArticles, prefs.newArticles)
|
||||
XCTAssertEqual(decoded.episodeReleases, prefs.episodeReleases)
|
||||
XCTAssertEqual(decoded.customAlerts, prefs.customAlerts)
|
||||
XCTAssertEqual(decoded.badgeCount, prefs.badgeCount)
|
||||
XCTAssertEqual(decoded.sound, prefs.sound)
|
||||
XCTAssertEqual(decoded.vibration, prefs.vibration)
|
||||
}
|
||||
|
||||
func testNotificationPreferencesDefaults() {
|
||||
let prefs = NotificationPreferences()
|
||||
|
||||
XCTAssertEqual(prefs.newArticles, true)
|
||||
XCTAssertEqual(prefs.episodeReleases, true)
|
||||
XCTAssertEqual(prefs.customAlerts, false)
|
||||
XCTAssertEqual(prefs.badgeCount, true)
|
||||
XCTAssertEqual(prefs.sound, true)
|
||||
XCTAssertEqual(prefs.vibration, true)
|
||||
}
|
||||
|
||||
func testNotificationPreferencesAllEnabled() {
|
||||
let allEnabled = NotificationPreferences(
|
||||
newArticles: true,
|
||||
episodeReleases: true,
|
||||
customAlerts: true,
|
||||
badgeCount: true,
|
||||
sound: true,
|
||||
vibration: true
|
||||
)
|
||||
|
||||
XCTAssertTrue(allEnabled.allEnabled)
|
||||
}
|
||||
|
||||
func testNotificationPreferencesAnyEnabled() {
|
||||
let noneEnabled = NotificationPreferences(
|
||||
newArticles: false,
|
||||
episodeReleases: false,
|
||||
customAlerts: false,
|
||||
badgeCount: false,
|
||||
sound: false,
|
||||
vibration: false
|
||||
)
|
||||
|
||||
XCTAssertFalse(noneEnabled.anyEnabled)
|
||||
|
||||
let someEnabled = NotificationPreferences(
|
||||
newArticles: true,
|
||||
episodeReleases: false,
|
||||
customAlerts: false,
|
||||
badgeCount: false,
|
||||
sound: false,
|
||||
vibration: false
|
||||
)
|
||||
|
||||
XCTAssertTrue(someEnabled.anyEnabled)
|
||||
}
|
||||
|
||||
func testNotificationPreferencesEquality() {
|
||||
let prefs1 = NotificationPreferences(newArticles: true, episodeReleases: false)
|
||||
let prefs2 = NotificationPreferences(newArticles: true, episodeReleases: false)
|
||||
let prefs3 = NotificationPreferences(newArticles: false, episodeReleases: true)
|
||||
|
||||
XCTAssertEqual(prefs1, prefs2)
|
||||
XCTAssertNotEqual(prefs1, prefs3)
|
||||
}
|
||||
|
||||
func testNotificationPreferencesDebugDescription() {
|
||||
let prefs = NotificationPreferences(
|
||||
newArticles: true,
|
||||
episodeReleases: false,
|
||||
customAlerts: true
|
||||
)
|
||||
|
||||
let debugDesc = prefs.debugDescription
|
||||
XCTAssertTrue(debugDesc.contains("true"))
|
||||
XCTAssertTrue(debugDesc.contains("false"))
|
||||
}
|
||||
}
|
||||
18
iOS/RSSuperTests/RSSuperTests.swift
Normal file
18
iOS/RSSuperTests/RSSuperTests.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// RSSuperTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import RSSuper
|
||||
|
||||
final class RSSuperTests: XCTestCase {
|
||||
|
||||
func testPlaceholder() {
|
||||
// Placeholder test - all actual tests are in dedicated test files
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
79
iOS/RSSuperTests/ReadingPreferencesTests.swift
Normal file
79
iOS/RSSuperTests/ReadingPreferencesTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ReadingPreferencesTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class ReadingPreferencesTests: XCTestCase {
|
||||
|
||||
func testReadingPreferencesEncodingDecoding() throws {
|
||||
let prefs = ReadingPreferences(
|
||||
fontSize: .large,
|
||||
lineHeight: .loose,
|
||||
showTableOfContents: true,
|
||||
showReadingTime: false,
|
||||
showAuthor: true,
|
||||
showDate: false
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(prefs)
|
||||
let decoded = try JSONDecoder().decode(ReadingPreferences.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.fontSize, prefs.fontSize)
|
||||
XCTAssertEqual(decoded.lineHeight, prefs.lineHeight)
|
||||
XCTAssertEqual(decoded.showTableOfContents, prefs.showTableOfContents)
|
||||
XCTAssertEqual(decoded.showReadingTime, prefs.showReadingTime)
|
||||
XCTAssertEqual(decoded.showAuthor, prefs.showAuthor)
|
||||
XCTAssertEqual(decoded.showDate, prefs.showDate)
|
||||
}
|
||||
|
||||
func testReadingPreferencesDefaults() {
|
||||
let prefs = ReadingPreferences()
|
||||
|
||||
XCTAssertEqual(prefs.fontSize, .medium)
|
||||
XCTAssertEqual(prefs.lineHeight, .relaxed)
|
||||
XCTAssertEqual(prefs.showTableOfContents, false)
|
||||
XCTAssertEqual(prefs.showReadingTime, true)
|
||||
XCTAssertEqual(prefs.showAuthor, true)
|
||||
XCTAssertEqual(prefs.showDate, true)
|
||||
}
|
||||
|
||||
func testFontSizePointValue() {
|
||||
XCTAssertEqual(ReadingPreferences.FontSize.small.pointValue, 14)
|
||||
XCTAssertEqual(ReadingPreferences.FontSize.medium.pointValue, 16)
|
||||
XCTAssertEqual(ReadingPreferences.FontSize.large.pointValue, 18)
|
||||
XCTAssertEqual(ReadingPreferences.FontSize.xlarge.pointValue, 20)
|
||||
}
|
||||
|
||||
func testLineHeightMultiplier() {
|
||||
XCTAssertEqual(ReadingPreferences.LineHeight.normal.multiplier, 1.2)
|
||||
XCTAssertEqual(ReadingPreferences.LineHeight.relaxed.multiplier, 1.5)
|
||||
XCTAssertEqual(ReadingPreferences.LineHeight.loose.multiplier, 1.8)
|
||||
}
|
||||
|
||||
func testReadingPreferencesEquality() {
|
||||
let prefs1 = ReadingPreferences(fontSize: .large, lineHeight: .loose)
|
||||
let prefs2 = ReadingPreferences(fontSize: .large, lineHeight: .loose)
|
||||
let prefs3 = ReadingPreferences(fontSize: .medium, lineHeight: .normal)
|
||||
|
||||
XCTAssertEqual(prefs1, prefs2)
|
||||
XCTAssertNotEqual(prefs1, prefs3)
|
||||
}
|
||||
|
||||
func testReadingPreferencesDebugDescription() {
|
||||
let prefs = ReadingPreferences(
|
||||
fontSize: .xlarge,
|
||||
lineHeight: .normal,
|
||||
showTableOfContents: true
|
||||
)
|
||||
|
||||
let debugDesc = prefs.debugDescription
|
||||
XCTAssertTrue(debugDesc.contains("xlarge"))
|
||||
XCTAssertTrue(debugDesc.contains("normal"))
|
||||
XCTAssertTrue(debugDesc.contains("true"))
|
||||
}
|
||||
}
|
||||
96
iOS/RSSuperTests/SearchFiltersTests.swift
Normal file
96
iOS/RSSuperTests/SearchFiltersTests.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// SearchFiltersTests.swift
|
||||
// RSSuperTests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
final class SearchFiltersTests: XCTestCase {
|
||||
|
||||
func testSearchFiltersEncodingDecoding() throws {
|
||||
let filters = SearchFilters(
|
||||
dateFrom: Date(timeIntervalSince1970: 1609459200),
|
||||
dateTo: Date(timeIntervalSince1970: 1609545600),
|
||||
feedIds: ["feed-1", "feed-2"],
|
||||
authors: ["Author 1", "Author 2"],
|
||||
contentType: .audio
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(filters)
|
||||
let decoded = try JSONDecoder().decode(SearchFilters.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.dateFrom, filters.dateFrom)
|
||||
XCTAssertEqual(decoded.dateTo, filters.dateTo)
|
||||
XCTAssertEqual(decoded.feedIds, filters.feedIds)
|
||||
XCTAssertEqual(decoded.authors, filters.authors)
|
||||
XCTAssertEqual(decoded.contentType, filters.contentType)
|
||||
}
|
||||
|
||||
func testSearchFiltersEmpty() throws {
|
||||
let filters = SearchFilters()
|
||||
|
||||
XCTAssertNil(filters.dateFrom)
|
||||
XCTAssertNil(filters.dateTo)
|
||||
XCTAssertNil(filters.feedIds)
|
||||
XCTAssertNil(filters.authors)
|
||||
XCTAssertNil(filters.contentType)
|
||||
|
||||
let data = try JSONEncoder().encode(filters)
|
||||
let decoded = try JSONDecoder().decode(SearchFilters.self, from: data)
|
||||
|
||||
XCTAssertNil(decoded.dateFrom)
|
||||
XCTAssertNil(decoded.feedIds)
|
||||
}
|
||||
|
||||
func testSearchFiltersEquality() {
|
||||
let filters1 = SearchFilters(
|
||||
feedIds: ["feed-1"],
|
||||
authors: ["Author 1"]
|
||||
)
|
||||
|
||||
let filters2 = SearchFilters(
|
||||
feedIds: ["feed-1"],
|
||||
authors: ["Author 1"]
|
||||
)
|
||||
|
||||
let filters3 = SearchFilters(
|
||||
feedIds: ["feed-2"],
|
||||
authors: ["Author 2"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(filters1, filters2)
|
||||
XCTAssertNotEqual(filters1, filters3)
|
||||
}
|
||||
|
||||
func testSearchSortOptionLocalizedDescription() {
|
||||
XCTAssertEqual(SearchSortOption.relevance.localizedDescription, "Relevance")
|
||||
XCTAssertEqual(SearchSortOption.dateDesc.localizedDescription, "Date (Newest First)")
|
||||
XCTAssertEqual(SearchSortOption.dateAsc.localizedDescription, "Date (Oldest First)")
|
||||
XCTAssertEqual(SearchSortOption.titleAsc.localizedDescription, "Title (A-Z)")
|
||||
XCTAssertEqual(SearchSortOption.titleDesc.localizedDescription, "Title (Z-A)")
|
||||
XCTAssertEqual(SearchSortOption.feedAsc.localizedDescription, "Feed (A-Z)")
|
||||
XCTAssertEqual(SearchSortOption.feedDesc.localizedDescription, "Feed (Z-A)")
|
||||
}
|
||||
|
||||
func testContentTypeLocalizedDescription() {
|
||||
XCTAssertEqual(ContentType.article.localizedDescription, "Article")
|
||||
XCTAssertEqual(ContentType.audio.localizedDescription, "Audio")
|
||||
XCTAssertEqual(ContentType.video.localizedDescription, "Video")
|
||||
}
|
||||
|
||||
func testSearchFiltersDebugDescription() {
|
||||
let filters = SearchFilters(
|
||||
dateFrom: Date(timeIntervalSince1970: 1609459200),
|
||||
feedIds: ["feed-1", "feed-2"],
|
||||
contentType: .video
|
||||
)
|
||||
|
||||
let debugDesc = filters.debugDescription
|
||||
XCTAssertTrue(debugDesc.contains("feed-1"))
|
||||
XCTAssertTrue(debugDesc.contains("feed-2"))
|
||||
XCTAssertTrue(debugDesc.contains("Video"))
|
||||
}
|
||||
}
|
||||
122
iOS/RSSuperTests/SearchHistoryStoreTests.swift
Normal file
122
iOS/RSSuperTests/SearchHistoryStoreTests.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
/// Unit tests for SearchHistoryStore
|
||||
final class SearchHistoryStoreTests: XCTestCase {
|
||||
|
||||
private var historyStore: SearchHistoryStore!
|
||||
private var databaseManager: DatabaseManager!
|
||||
|
||||
override func setUp() async throws {
|
||||
// Create in-memory database for testing
|
||||
databaseManager = try await DatabaseManager.inMemory()
|
||||
historyStore = SearchHistoryStore(databaseManager: databaseManager, maxHistoryCount: 10)
|
||||
try await historyStore.initialize()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
historyStore = nil
|
||||
databaseManager = nil
|
||||
}
|
||||
|
||||
func testRecordSearch() async throws {
|
||||
try await historyStore.recordSearch("test query")
|
||||
|
||||
let exists = try await historyStore.queryExists("test query")
|
||||
XCTAssertTrue(exists)
|
||||
}
|
||||
|
||||
func testRecordSearchUpdatesExisting() async throws {
|
||||
try await historyStore.recordSearch("test query")
|
||||
let firstCount = try await historyStore.getTotalCount()
|
||||
|
||||
try await historyStore.recordSearch("test query")
|
||||
let secondCount = try await historyStore.getTotalCount()
|
||||
|
||||
XCTAssertEqual(firstCount, secondCount) // Should be same, updated not inserted
|
||||
}
|
||||
|
||||
func testGetRecentQueries() async throws {
|
||||
try await historyStore.recordSearch("query 1")
|
||||
try await historyStore.recordSearch("query 2")
|
||||
try await historyStore.recordSearch("query 3")
|
||||
|
||||
let queries = try await historyStore.getRecentQueries(limit: 2)
|
||||
|
||||
XCTAssertEqual(queries.count, 2)
|
||||
XCTAssertEqual(queries[0], "query 3") // Most recent first
|
||||
XCTAssertEqual(queries[1], "query 2")
|
||||
}
|
||||
|
||||
func testGetHistoryWithMetadata() async throws {
|
||||
try await historyStore.recordSearch("test query", resultCount: 42)
|
||||
|
||||
let entries = try await historyStore.getHistoryWithMetadata(limit: 10)
|
||||
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertEqual(entries[0].query, "test query")
|
||||
XCTAssertEqual(entries[0].resultCount, 42)
|
||||
}
|
||||
|
||||
func testRemoveQuery() async throws {
|
||||
try await historyStore.recordSearch("to remove")
|
||||
XCTAssertTrue(try await historyStore.queryExists("to remove"))
|
||||
|
||||
try await historyStore.removeQuery("to remove")
|
||||
XCTAssertFalse(try await historyStore.queryExists("to remove"))
|
||||
}
|
||||
|
||||
func testClearHistory() async throws {
|
||||
try await historyStore.recordSearch("query 1")
|
||||
try await historyStore.recordSearch("query 2")
|
||||
|
||||
XCTAssertEqual(try await historyStore.getTotalCount(), 2)
|
||||
|
||||
try await historyStore.clearHistory()
|
||||
XCTAssertEqual(try await historyStore.getTotalCount(), 0)
|
||||
}
|
||||
|
||||
func testTrimHistory() async throws {
|
||||
// Insert more than maxHistoryCount
|
||||
for i in 1...15 {
|
||||
try await historyStore.recordSearch("query \(i)")
|
||||
}
|
||||
|
||||
let count = try await historyStore.getTotalCount()
|
||||
XCTAssertEqual(count, 10) // Should be trimmed to maxHistoryCount
|
||||
}
|
||||
|
||||
func testGetPopularQueries() async throws {
|
||||
// Record queries with different frequencies
|
||||
try await historyStore.recordSearch("popular")
|
||||
try await historyStore.recordSearch("popular")
|
||||
try await historyStore.recordSearch("popular")
|
||||
try await historyStore.recordSearch("less popular")
|
||||
try await historyStore.recordSearch("less popular")
|
||||
try await historyStore.recordSearch("once")
|
||||
|
||||
let popular = try await historyStore.getPopularQueries(limit: 10)
|
||||
|
||||
XCTAssertEqual(popular.count, 3)
|
||||
XCTAssertEqual(popular[0].query, "popular")
|
||||
XCTAssertEqual(popular[0].count, 3)
|
||||
}
|
||||
|
||||
func testGetTodaysQueries() async throws {
|
||||
try await historyStore.recordSearch("today query 1")
|
||||
try await historyStore.recordSearch("today query 2")
|
||||
|
||||
let todays = try await historyStore.getTodaysQueries()
|
||||
|
||||
XCTAssertTrue(todays.contains("today query 1"))
|
||||
XCTAssertTrue(todays.contains("today query 2"))
|
||||
}
|
||||
|
||||
func testEmptyQueryIgnored() async throws {
|
||||
try await historyStore.recordSearch("")
|
||||
try await historyStore.recordSearch(" ")
|
||||
|
||||
let count = try await historyStore.getTotalCount()
|
||||
XCTAssertEqual(count, 0)
|
||||
}
|
||||
}
|
||||
111
iOS/RSSuperTests/SearchQueryTests.swift
Normal file
111
iOS/RSSuperTests/SearchQueryTests.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
/// Unit tests for SearchQuery parsing and manipulation
|
||||
final class SearchQueryTests: XCTestCase {
|
||||
|
||||
func testEmptyQuery() {
|
||||
let query = SearchQuery(rawValue: "")
|
||||
|
||||
XCTAssertEqual(query.terms, [])
|
||||
XCTAssertEqual(query.rawText, "")
|
||||
XCTAssertEqual(query.sort, .relevance)
|
||||
XCTAssertFalse(query.fuzzy)
|
||||
}
|
||||
|
||||
func testSimpleQuery() {
|
||||
let query = SearchQuery(rawValue: "swift programming")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift", "programming"])
|
||||
XCTAssertEqual(query.rawText, "swift programming")
|
||||
}
|
||||
|
||||
func testQueryWithDateFilter() {
|
||||
let query = SearchQuery(rawValue: "swift date:after:2024-01-01")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift"])
|
||||
XCTAssertNotNil(query.filters.dateRange)
|
||||
|
||||
if case .after(let date) = query.filters.dateRange! {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let expectedDate = formatter.date(from: "2024-01-01")!
|
||||
XCTAssertEqual(date, expectedDate)
|
||||
} else {
|
||||
XCTFail("Expected .after case")
|
||||
}
|
||||
}
|
||||
|
||||
func testQueryWithFeedFilter() {
|
||||
let query = SearchQuery(rawValue: "swift feed:Apple Developer")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift"])
|
||||
XCTAssertEqual(query.filters.feedTitle, "Apple Developer")
|
||||
}
|
||||
|
||||
func testQueryWithAuthorFilter() {
|
||||
let query = SearchQuery(rawValue: "swift author:John Doe")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift"])
|
||||
XCTAssertEqual(query.filters.author, "John Doe")
|
||||
}
|
||||
|
||||
func testQueryWithSortOption() {
|
||||
let query = SearchQuery(rawValue: "swift sort:date_desc")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift"])
|
||||
XCTAssertEqual(query.sort, .dateDesc)
|
||||
}
|
||||
|
||||
func testQueryWithFuzzyFlag() {
|
||||
let query = SearchQuery(rawValue: "swift ~")
|
||||
|
||||
XCTAssertEqual(query.terms, ["swift"])
|
||||
XCTAssertTrue(query.fuzzy)
|
||||
}
|
||||
|
||||
func testFTSQueryGeneration() {
|
||||
let exactQuery = SearchQuery(rawValue: "swift programming")
|
||||
XCTAssertEqual(exactQuery.ftsQuery(), "\"swift\" OR \"programming\"")
|
||||
|
||||
let fuzzyQuery = SearchQuery(rawValue: "swift ~")
|
||||
XCTAssertEqual(fuzzyQuery.ftsQuery(), "\"*swift*\"")
|
||||
}
|
||||
|
||||
func testDisplayString() {
|
||||
let query = SearchQuery(rawValue: "swift date:after:2024-01-01")
|
||||
XCTAssertEqual(query.displayString, "swift")
|
||||
}
|
||||
|
||||
func testDateRangeLowerBound() {
|
||||
let afterRange = DateRange.after(Date())
|
||||
XCTAssertNotNil(afterRange.lowerBound)
|
||||
XCTAssertNil(afterRange.upperBound)
|
||||
|
||||
let beforeRange = DateRange.before(Date())
|
||||
XCTAssertNil(beforeRange.lowerBound)
|
||||
XCTAssertNotNil(beforeRange.upperBound)
|
||||
|
||||
let exactRange = DateRange.exact(Date())
|
||||
XCTAssertNotNil(exactRange.lowerBound)
|
||||
XCTAssertNotNil(exactRange.upperBound)
|
||||
}
|
||||
|
||||
func testSearchFiltersIsEmpty() {
|
||||
var filters = SearchFilters()
|
||||
XCTAssertTrue(filters.isEmpty)
|
||||
|
||||
filters.dateRange = .after(Date())
|
||||
XCTAssertFalse(filters.isEmpty)
|
||||
|
||||
filters = .empty
|
||||
XCTAssertTrue(filters.isEmpty)
|
||||
}
|
||||
|
||||
func testSortOptionOrderByClause() {
|
||||
XCTAssertEqual(SearchSortOption.relevance.orderByClause(), "rank")
|
||||
XCTAssertEqual(SearchSortOption.dateDesc.orderByClause(), "f.published DESC")
|
||||
XCTAssertEqual(SearchSortOption.titleAsc.orderByClause(), "f.title ASC")
|
||||
XCTAssertEqual(SearchSortOption.feedDesc.orderByClause(), "s.title DESC")
|
||||
}
|
||||
}
|
||||
89
iOS/RSSuperTests/SearchResultTests.swift
Normal file
89
iOS/RSSuperTests/SearchResultTests.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
/// Unit tests for SearchResult and related types
|
||||
final class SearchResultTests: XCTestCase {
|
||||
|
||||
func testArticleResultCreation() {
|
||||
let result = SearchResult.article(
|
||||
id: "article-123",
|
||||
title: "Test Article",
|
||||
snippet: "This is a snippet",
|
||||
link: "https://example.com/article",
|
||||
feedTitle: "Test Feed",
|
||||
published: Date(),
|
||||
score: 0.95,
|
||||
author: "Test Author"
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.id, "article-123")
|
||||
XCTAssertEqual(result.type, .article)
|
||||
XCTAssertEqual(result.title, "Test Article")
|
||||
XCTAssertEqual(result.snippet, "This is a snippet")
|
||||
XCTAssertEqual(result.link, "https://example.com/article")
|
||||
XCTAssertEqual(result.feedTitle, "Test Feed")
|
||||
XCTAssertEqual(result.score, 0.95)
|
||||
XCTAssertEqual(result.author, "Test Author")
|
||||
}
|
||||
|
||||
func testFeedResultCreation() {
|
||||
let result = SearchResult.feed(
|
||||
id: "feed-456",
|
||||
title: "Test Feed",
|
||||
link: "https://example.com/feed.xml",
|
||||
score: 0.85
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.id, "feed-456")
|
||||
XCTAssertEqual(result.type, .feed)
|
||||
XCTAssertEqual(result.title, "Test Feed")
|
||||
XCTAssertEqual(result.link, "https://example.com/feed.xml")
|
||||
XCTAssertEqual(result.score, 0.85)
|
||||
}
|
||||
|
||||
func testSuggestionResultCreation() {
|
||||
let result = SearchResult.suggestion(
|
||||
text: "swift programming",
|
||||
score: 0.75
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.type, .suggestion)
|
||||
XCTAssertEqual(result.title, "swift programming")
|
||||
XCTAssertEqual(result.score, 0.75)
|
||||
}
|
||||
|
||||
func testSearchResultTypeEncoding() {
|
||||
XCTAssertEqual(SearchResultType.article.rawValue, "article")
|
||||
XCTAssertEqual(SearchResultType.feed.rawValue, "feed")
|
||||
XCTAssertEqual(SearchResultType.suggestion.rawValue, "suggestion")
|
||||
XCTAssertEqual(SearchResultType.tag.rawValue, "tag")
|
||||
XCTAssertEqual(SearchResultType.author.rawValue, "author")
|
||||
}
|
||||
|
||||
func testSearchResultEquatable() {
|
||||
let result1 = SearchResult.article(id: "1", title: "Test")
|
||||
let result2 = SearchResult.article(id: "1", title: "Test")
|
||||
let result3 = SearchResult.article(id: "2", title: "Test")
|
||||
|
||||
XCTAssertEqual(result1, result2)
|
||||
XCTAssertNotEqual(result1, result3)
|
||||
}
|
||||
|
||||
func testSearchResults totalCount() {
|
||||
let results = SearchResults(
|
||||
articles: [SearchResult.article(id: "1", title: "A")],
|
||||
feeds: [SearchResult.feed(id: "2", title: "F")],
|
||||
suggestions: []
|
||||
)
|
||||
|
||||
XCTAssertEqual(results.totalCount, 2)
|
||||
XCTAssertTrue(results.hasResults)
|
||||
}
|
||||
|
||||
func testSearchResultsEmpty() {
|
||||
let results = SearchResults(articles: [], feeds: [], suggestions: [])
|
||||
|
||||
XCTAssertEqual(results.totalCount, 0)
|
||||
XCTAssertFalse(results.hasResults)
|
||||
}
|
||||
}
|
||||
76
iOS/RSSuperTests/SyncSchedulerTests.swift
Normal file
76
iOS/RSSuperTests/SyncSchedulerTests.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
/// Unit tests for SyncScheduler
|
||||
final class SyncSchedulerTests: XCTestCase {
|
||||
|
||||
private var scheduler: SyncScheduler!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
scheduler = SyncScheduler()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
scheduler = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testDefaultSyncInterval() {
|
||||
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval)
|
||||
}
|
||||
|
||||
func testSyncIntervalClamping() {
|
||||
// Test minimum clamping
|
||||
scheduler.preferredSyncInterval = 60 // 1 minute
|
||||
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.minimumSyncInterval)
|
||||
|
||||
// Test maximum clamping
|
||||
scheduler.preferredSyncInterval = 48 * 3600 // 48 hours
|
||||
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.maximumSyncInterval)
|
||||
}
|
||||
|
||||
func testIsSyncDue() {
|
||||
// Fresh scheduler should have sync due
|
||||
XCTAssertTrue(scheduler.isSyncDue)
|
||||
|
||||
// Set last sync date to recent past
|
||||
scheduler.lastSyncDate = Date().addingTimeInterval(-1 * 3600) // 1 hour ago
|
||||
XCTAssertFalse(scheduler.isSyncDue)
|
||||
|
||||
// Set last sync date to far past
|
||||
scheduler.lastSyncDate = Date().addingTimeInterval(-12 * 3600) // 12 hours ago
|
||||
XCTAssertTrue(scheduler.isSyncDue)
|
||||
}
|
||||
|
||||
func testTimeSinceLastSync() {
|
||||
scheduler.lastSyncDate = Date().addingTimeInterval(-3600) // 1 hour ago
|
||||
|
||||
let timeSince = scheduler.timeSinceLastSync
|
||||
XCTAssertGreaterThan(timeSince, 3500)
|
||||
XCTAssertLessThan(timeSince, 3700)
|
||||
}
|
||||
|
||||
func testResetSyncSchedule() {
|
||||
scheduler.preferredSyncInterval = 12 * 3600
|
||||
scheduler.lastSyncDate = Date().addingTimeInterval(-100)
|
||||
|
||||
scheduler.resetSyncSchedule()
|
||||
|
||||
XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval)
|
||||
XCTAssertNil(scheduler.lastSyncDate)
|
||||
}
|
||||
|
||||
func testUserActivityLevelCalculation() {
|
||||
// High activity
|
||||
XCTAssertEqual(UserActivityLevel.calculate(from: 5, lastOpenedAgo: 3600), .high)
|
||||
XCTAssertEqual(UserActivityLevel.calculate(from: 1, lastOpenedAgo: 60), .high)
|
||||
|
||||
// Medium activity
|
||||
XCTAssertEqual(UserActivityLevel.calculate(from: 2, lastOpenedAgo: 3600), .medium)
|
||||
XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 43200), .medium)
|
||||
|
||||
// Low activity
|
||||
XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 172800), .low)
|
||||
}
|
||||
}
|
||||
41
iOS/RSSuperUITests/RSSuperUITests.swift
Normal file
41
iOS/RSSuperUITests/RSSuperUITests.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// RSSuperUITests.swift
|
||||
// RSSuperUITests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class RSSuperUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
iOS/RSSuperUITests/RSSuperUITestsLaunchTests.swift
Normal file
33
iOS/RSSuperUITests/RSSuperUITestsLaunchTests.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// RSSuperUITestsLaunchTests.swift
|
||||
// RSSuperUITests
|
||||
//
|
||||
// Created by Mike Freno on 3/29/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class RSSuperUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user