diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..063f69e --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,120 @@ +# Directory and file filters +included: + - Plugins + - Source + - Tests + - Package.swift +excluded: + - Tests/BuiltInRulesTests/Resources + - Tests/FrameworkTests/Resources + +# Enabled/disabled rules +analyzer_rules: + - unused_declaration + - unused_import +opt_in_rules: + - all +disabled_rules: + - anonymous_argument_in_multiline_closure + - async_without_await + - conditional_returns_on_newline + - contrasted_opening_brace + - convenience_type + - discouraged_optional_collection + - explicit_acl + - explicit_enum_raw_value + - explicit_top_level_acl + - explicit_type_interface + - file_types_order + - force_unwrapping + - function_default_parameter_at_end + - indentation_width + - missing_docs + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_parameters_brackets + - no_extension_access_modifier + - no_grouping_extension + - no_magic_numbers + - one_declaration_per_file + - prefer_key_path # Re-enable once we are on Swift 6. + - prefer_nimble + - prefixed_toplevel_constant + - required_deinit + - sorted_enum_cases + - strict_fileprivate + - switch_case_on_newline + - todo + - trailing_closure + - type_contents_order + - vertical_whitespace_between_cases + +# Configurations +attributes: + always_on_line_above: + - "@ConfigurationElement" + - "@OptionGroup" + - "@RuleConfigurationDescriptionBuilder" +balanced_xctest_lifecycle: &unit_test_configuration + test_parent_classes: + - SwiftLintTestCase + - XCTestCase +closure_body_length: + warning: 50 + error: 100 +empty_xctest_method: *unit_test_configuration +file_name: + excluded: + - Exports.swift + - GeneratedTests.swift + - Macros.swift + - Reporters+Register.swift + - Rules+Register.swift + - Rules+Template.swift + - RuleConfigurationMacros.swift + - SwiftSyntax+SwiftLint.swift + - TestHelpers.swift +final_test_case: *unit_test_configuration +function_body_length: 60 +identifier_name: + excluded: + - id +large_tuple: 3 +number_separator: + minimum_length: 5 +redundant_type_annotation: + consider_default_literal_types_redundant: true +single_test_class: *unit_test_configuration +trailing_comma: + mandatory_comma: true +type_body_length: 400 +unneeded_override: + affect_initializers: true +unused_import: + always_keep_imports: + - SwiftSyntaxBuilder # we can't detect uses of string interpolation of swift syntax nodes + - SwiftLintFramework # now that this is a wrapper around other modules, don't treat as unused + +# Custom rules +custom_rules: + rule_id: + included: Source/SwiftLintBuiltInRules/Rules/.+/\w+\.swift + name: Rule ID + message: Rule IDs must be all lowercase, snake case and not end with `rule` + regex: ^\s+identifier:\s*("\w+_rule"|"\S*[^a-z_]\S*") + severity: error + fatal_error: + name: Fatal Error + excluded: "Tests/*" + message: Prefer using `queuedFatalError` over `fatalError` to avoid leaking compiler host machine paths. + regex: \bfatalError\b + match_kinds: + - identifier + severity: error + rule_test_function: + included: Tests/SwiftLintFrameworkTests/RulesTests.swift + name: Rule Test Function + message: Rule Test Function mustn't end with `rule` + regex: func\s*test\w+(r|R)ule\(\) + severity: error diff --git a/Localizable.xcstrings b/Localizable.xcstrings index be3a7a3..d950c03 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1109,6 +1109,16 @@ } } }, + "No Content Available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Inhalt verfügbar" + } + } + } + }, "No data received" : { "localizations" : { "de" : { @@ -1689,6 +1699,16 @@ } } }, + "Try updating the Module" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versuche das Modul zu updaten" + } + } + } + }, "Two Finger Hold for Pause" : { "localizations" : { "de" : { diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements index da83a05..0375f68 100644 --- a/Sora/Sora.entitlements +++ b/Sora/Sora.entitlements @@ -4,7 +4,7 @@ com.apple.developer.icloud-container-identifiers - iCloud.de.devsforge.sulfur.fork + iCloud.de.devsforge.sulfur com.apple.developer.icloud-services @@ -12,7 +12,7 @@ com.apple.developer.ubiquity-container-identifiers - iCloud.de.devsforge.sulfur.fork + iCloud.de.devsforge.sulfur com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/Sora/Utils/JSLoader/JSController-Explore.swift b/Sora/Utils/JSLoader/JSController-Explore.swift new file mode 100644 index 0000000..d4eeab5 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Explore.swift @@ -0,0 +1,133 @@ +// +// JSController-Search.swift +// Sulfur +// +// Created by Dominic on 24.04.25. +// + +import JavaScriptCore + +// TODO: implement and test +extension JSController { + + func fetchExploreResults(module: ScrapingModule, completion: @escaping ([ExploreItem]) -> Void) { + /*let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + + guard let url = URL(string: searchUrl) else { + completion([]) + return + } + + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error { + Logger.shared.log("Network error: \(error)",type: "Error") + DispatchQueue.main.async { completion([]) } + return + } + + guard let data = data, let html = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to decode HTML",type: "Error") + DispatchQueue.main.async { completion([]) } + return + } + + Logger.shared.log(html,type: "HTMLStrings") + if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), + let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { + let resultItems = results.map { item in + SearchItem( + title: item["title"] ?? "", + imageUrl: item["image"] ?? "", + href: item["href"] ?? "" + ) + } + DispatchQueue.main.async { + completion(resultItems) + } + } else { + Logger.shared.log("Failed to parse results",type: "Error") + DispatchQueue.main.async { completion([]) } + } + }.resume() + */ + } + + func fetchJsExploreResults(module: ScrapingModule, completion: @escaping ([ExploreItem]) -> Void) { + /* + if let exception = context.exception { + Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + completion([]) + return + } + + guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { + Logger.shared.log("No JavaScript function searchResults found",type: "Error") + completion([]) + return + } + + let promiseValue = searchResultsFunction.call(withArguments: [keyword]) + guard let promise = promiseValue else { + Logger.shared.log("searchResults did not return a Promise",type: "Error") + completion([]) + return + } + + let thenBlock: @convention(block) (JSValue) -> Void = { result in + + Logger.shared.log(result.toString(),type: "HTMLStrings") + if let jsonString = result.toString(), + let data = jsonString.data(using: .utf8) { + do { + if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] { + let resultItems = array.compactMap { item -> SearchItem? in + guard let title = item["title"] as? String, + let imageUrl = item["image"] as? String, + let href = item["href"] as? String else { + Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") + return nil + } + return SearchItem(title: title, imageUrl: imageUrl, href: href) + } + + DispatchQueue.main.async { + completion(resultItems) + } + + } else { + Logger.shared.log("Failed to parse JSON",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } catch { + Logger.shared.log("JSON parsing error: \(error)",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } else { + Logger.shared.log("Result is not a string",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + } + + let catchBlock: @convention(block) (JSValue) -> Void = { error in + Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") + DispatchQueue.main.async { + completion([]) + } + } + + let thenFunction = JSValue(object: thenBlock, in: context) + let catchFunction = JSValue(object: catchBlock, in: context) + + promise.invokeMethod("then", withArguments: [thenFunction as Any]) + promise.invokeMethod("catch", withArguments: [catchFunction as Any]) + */ + } +} diff --git a/Sora/Utils/SkeletonCells/SkeletonCell.swift b/Sora/Utils/SkeletonCells/SkeletonCell.swift index 342b2bf..b8d2bd1 100644 --- a/Sora/Utils/SkeletonCells/SkeletonCell.swift +++ b/Sora/Utils/SkeletonCells/SkeletonCell.swift @@ -7,38 +7,30 @@ import SwiftUI -struct HomeSkeletonCell: View { +enum SkeletonCellType { + // unused !? ( legacy code from HomeSkeletonCell ) + case home + + case search + case explore +} + +struct SkeletonCell: View { + let type: SkeletonCellType let cellWidth: CGFloat - + var body: some View { - VStack { + VStack(alignment: type == .home ? .center : .leading, spacing: type == .home ? 0 : 8) { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) .frame(width: cellWidth, height: cellWidth * 1.5) .cornerRadius(10) .shimmering() - - RoundedRectangle(cornerRadius: 5) - .fill(Color.gray.opacity(0.3)) - .frame(width: cellWidth, height: 20) - .padding(.top, 4) - .shimmering() - } - } -} -struct SearchSkeletonCell: View { - let cellWidth: CGFloat - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: cellWidth, height: cellWidth * 1.5) - .shimmering() RoundedRectangle(cornerRadius: 5) .fill(Color.gray.opacity(0.3)) .frame(width: cellWidth, height: 20) + .padding(.top, type == .home ? 4 : 0) .shimmering() } } diff --git a/Sora/Views/ExploreView/ExploreView.swift b/Sora/Views/ExploreView/ExploreView.swift index 0e12363..d811df3 100644 --- a/Sora/Views/ExploreView/ExploreView.swift +++ b/Sora/Views/ExploreView/ExploreView.swift @@ -1,35 +1,52 @@ // // LibraryView.swift -// Sora +// Sulfur // -// Created by Francesco on 05/01/25. +// Created by Dominic on 24.04.25. // import SwiftUI import Kingfisher -struct ExploreView: View { - @EnvironmentObject private var moduleManager: ModuleManager - @EnvironmentObject private var profileStore: ProfileStore +struct ExploreItem: Identifiable { + let id = UUID() + let title: String + let imageUrl: String + let href: String +} - @AppStorage("selectedModuleId") private var selectedModuleId: String? +struct ExploreView: View { @AppStorage("hideEmptySections") private var hideEmptySections: Bool? + @AppStorage("selectedModuleId") private var selectedModuleId: String? @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - + + @StateObject private var jsController = JSController() + @EnvironmentObject private var moduleManager: ModuleManager + @EnvironmentObject private var profileStore: ProfileStore @Environment(\.verticalSizeClass) var verticalSizeClass + + @State private var exploreItems: [ExploreItem] = [] + @State private var selectedExploreItem: ExploreItem? + @State private var hasNoResults = false @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape + @State private var isModuleSelectorPresented = false @State private var showProfileSettings = false - + @State private var isLoading = false + private var selectedModule: ScrapingModule? { guard let id = selectedModuleId else { return nil } return moduleManager.modules.first { $0.id.uuidString == id } } - private let columns = [ - GridItem(.adaptive(minimum: 150), spacing: 12) + private var loadingMessages: [String] = [ + "Exploring the depths...", + "Looking for results...", + "Fetching data...", + "Please wait...", + "Almost there..." ] - + private var columnsCount: Int { if UIDevice.current.userInterfaceIdiom == .pad { let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height @@ -38,7 +55,7 @@ struct ExploreView: View { return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } - + private var cellWidth: CGFloat { let keyWindow = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } @@ -49,15 +66,83 @@ struct ExploreView: View { let availableWidth = safeWidth - totalSpacing return availableWidth / CGFloat(columnsCount) } - + var body: some View { NavigationView { ScrollView { - VStack(alignment: .leading, spacing: 12) { - //TODO: add explore content views + let columnsCount = determineColumns() + VStack(spacing: 0) { + + if !(hideEmptySections ?? false) && selectedModule == nil { + VStack(spacing: 8) { + Image(systemName: "questionmark.app") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No Module Selected") + .font(.headline) + Text("Please select a module from settings") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + } + + if isLoading { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) { + ForEach(0.. Int { + if UIDevice.current.userInterfaceIdiom == .pad { + return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait + } else { + return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait + } + } + private func cleanLanguageName(_ language: String?) -> String { guard let language = language else { return "Unknown" } diff --git a/Sora/Views/SearchView/SearchView.swift b/Sora/Views/SearchView/SearchView.swift index be75e6d..ba0fada 100644 --- a/Sora/Views/SearchView/SearchView.swift +++ b/Sora/Views/SearchView/SearchView.swift @@ -111,7 +111,7 @@ struct SearchView: View { if isSearching { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) { ForEach(0..