diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index 2c3b4a6ee..73906ec09 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -44,6 +44,13 @@ final class AccountsDetailViewController: NSViewController { accountsFeedbinWindowController.account = account accountsFeedbinWindowController.runSheetOnWindow(window) + case .miniflux: + let accountsMinifluxWindowController = AccountsMinifluxWindowController() + accountsWindowController = accountsMinifluxWindowController + accountsMinifluxWindowController.accountType = account.type + accountsMinifluxWindowController.account = account + accountsMinifluxWindowController.runSheetOnWindow(window) + case .inoreader, .bazQux, .theOldReader, .freshRSS: let accountsReaderAPIWindowController = AccountsReaderAPIWindowController() accountsWindowController = accountsReaderAPIWindowController diff --git a/Mac/Preferences/Accounts/AccountsMiniflux.xib b/Mac/Preferences/Accounts/AccountsMiniflux.xib new file mode 100644 index 000000000..a7fa999e9 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsMiniflux.xib @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsMinifluxWindowController.swift b/Mac/Preferences/Accounts/AccountsMinifluxWindowController.swift new file mode 100644 index 000000000..5da3a935a --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsMinifluxWindowController.swift @@ -0,0 +1,149 @@ +// +// AccountsMinifluxWindowController.swift +// NetNewsWire +// +// Created by Ingmar Stein on 6/18/26. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb +import Secrets + +final class AccountsMinifluxWindowController: NSWindowController { + + @IBOutlet var titleImageView: NSImageView! + @IBOutlet var titleLabel: NSTextField! + + @IBOutlet var gridView: NSGridView! + @IBOutlet var progressIndicator: NSProgressIndicator! + @IBOutlet var apiURLTextField: NSTextField! + @IBOutlet var apiKeyTextField: NSSecureTextField! + @IBOutlet var createAccountButton: NSButton! + @IBOutlet var errorMessageLabel: NSTextField! + @IBOutlet var actionButton: NSButton! + @IBOutlet var noAccountTextField: NSTextField! + + var account: Account? + var accountType: AccountType? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsMiniflux")) + } + + override func windowDidLoad() { + titleImageView.image = Assets.Images.accountMiniflux + titleLabel.stringValue = NSLocalizedString("Sign in to your Miniflux instance.", comment: "Miniflux") + noAccountTextField.stringValue = NSLocalizedString("Don't have a Miniflux instance?", comment: "No Miniflux") + createAccountButton.title = NSLocalizedString("Find out more", comment: "No Miniflux Button") + apiURLTextField.placeholderString = NSLocalizedString("https://miniflux.example.com", comment: "Miniflux API URL Helper") + + if let account = account, let credentials = try? account.retrieveCredentials(type: .minifluxAPIKey) { + apiURLTextField.stringValue = account.endpointURL?.absoluteString ?? "" + apiKeyTextField.stringValue = credentials.secret + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + + apiURLTextField.becomeFirstResponder() + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) { + guard let window else { + return + } + + self.hostWindow = hostWindow + hostWindow.beginSheet(window, completionHandler: completion) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + self.errorMessageLabel.stringValue = "" + + guard !apiURLTextField.stringValue.isEmpty && !apiKeyTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("API URL and API Key are required.", comment: "Credentials Error") + return + } + + let accountType: AccountType = .miniflux + + guard let inputURL = URL(string: apiURLTextField.stringValue.trimmingWhitespace) else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid API URL.", comment: "Invalid API URL") + return + } + let apiURL = inputURL + + guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: accountType, endpointURL: apiURL) else { + self.errorMessageLabel.stringValue = NSLocalizedString("There is already a Miniflux account with that URL created.", comment: "Duplicate Error") + return + } + + Task { @MainActor in + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + @MainActor func stopAnimation() { + actionButton.isEnabled = true + progressIndicator.isHidden = true + progressIndicator.stopAnimation(self) + } + + let trimmedAPIKey = apiKeyTextField.stringValue.trimmingWhitespace + let credentials = Credentials(type: .minifluxAPIKey, username: trimmedAPIKey, secret: trimmedAPIKey) + do { + let validatedCredentials = try await Account.validateCredentials(type: accountType, credentials: credentials, endpoint: apiURL) + stopAnimation() + + guard let validatedCredentials else { + errorMessageLabel.stringValue = NSLocalizedString("Invalid API key.", comment: "Credentials Error") + return + } + + if account == nil { + account = AccountManager.shared.createAccount(type: accountType) + } + + do { + account?.endpointURL = apiURL + + try account?.storeCredentials(validatedCredentials) + + do { + try await account?.refreshAll() + } catch { + NSApplication.shared.presentError(error) + } + + hostWindow?.endSheet(window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + } catch { + stopAnimation() + if case AccountError.urlNotFound = error { + errorMessageLabel.stringValue = NSLocalizedString("The API URL couldn't be found. Please check the URL.", comment: "API URL not found") + } else { + errorMessageLabel.stringValue = error.localizedDescription + } + } + } + } + + @IBAction func createAccountWithProvider(_ sender: Any) { + NSWorkspace.shared.open(URL(string: "https://miniflux.app")!) + } +} diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index a4d32bc49..d003709cb 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -185,6 +185,11 @@ extension AccountsPreferencesViewController: AccountsPreferencesAddAccountDelega accountsReaderAPIWindowController.accountType = accountType accountsReaderAPIWindowController.runSheetOnWindow(window) + case .miniflux: + let accountsMinifluxWindowController = AccountsMinifluxWindowController() + addAccountWindowController = accountsMinifluxWindowController + accountsMinifluxWindowController.runSheetOnWindow(window) + case .feedly: let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) addAccount.delegate = self diff --git a/Mac/Preferences/Accounts/AddAccountsView.swift b/Mac/Preferences/Accounts/AddAccountsView.swift index f14d00827..83c67b243 100644 --- a/Mac/Preferences/Accounts/AddAccountsView.swift +++ b/Mac/Preferences/Accounts/AddAccountsView.swift @@ -60,7 +60,7 @@ enum AddAccountSections: Int, CaseIterable { return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] } case .selfhosted: - return [.freshRSS] + return [.freshRSS, .miniflux] case .allOrdered: return AddAccountSections.local.sectionContent + AddAccountSections.icloud.sectionContent + diff --git a/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json new file mode 100644 index 000000000..ebc32f28a --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "accountMiniflux.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf b/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf new file mode 100644 index 000000000..225fc2cef Binary files /dev/null and b/Mac/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf differ diff --git a/Mac/Scripting/Account+Scriptability.swift b/Mac/Scripting/Account+Scriptability.swift index 11116ad84..3ea02da06 100644 --- a/Mac/Scripting/Account+Scriptability.swift +++ b/Mac/Scripting/Account+Scriptability.swift @@ -230,6 +230,8 @@ import RSCore osType = "Bzqx" case .theOldReader: osType = "Tord" + case .miniflux: + osType = "Mnfl" } return osType.fourCharCode } diff --git a/Modules/Account/Sources/Account/Account.swift b/Modules/Account/Sources/Account/Account.swift index ba5b877d2..a2dd9e571 100644 --- a/Modules/Account/Sources/Account/Account.swift +++ b/Modules/Account/Sources/Account/Account.swift @@ -48,6 +48,7 @@ nonisolated public enum AccountType: Int, Codable, Sendable { case inoreader = 21 case bazQux = 22 case theOldReader = 23 + case miniflux = 24 public var isDeveloperRestricted: Bool { return self == .cloudKit || self == .feedbin || self == .feedly || self == .inoreader @@ -74,6 +75,8 @@ nonisolated public enum AccountType: Int, Codable, Sendable { return NSLocalizedString("BazQux", comment: "Account name") case .theOldReader: return NSLocalizedString("The Old Reader", comment: "Account name") + case .miniflux: + return "Miniflux" } } } @@ -312,6 +315,8 @@ public enum FetchType { self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, variant: .bazQux) case .theOldReader: self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, variant: .theOldReader) + case .miniflux: + self.delegate = MinifluxAccountDelegate(dataFolder: dataFolder) } self.accountID = accountID @@ -410,6 +415,8 @@ public enum FetchType { return try await NewsBlurAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint) case .freshRSS, .inoreader, .bazQux, .theOldReader: return try await ReaderAPIAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint) + case .miniflux: + return try await MinifluxAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint) default: return nil } diff --git a/Modules/Account/Sources/Account/AccountManager.swift b/Modules/Account/Sources/Account/AccountManager.swift index e8f6a0ab6..e5ec00df2 100644 --- a/Modules/Account/Sources/Account/AccountManager.swift +++ b/Modules/Account/Sources/Account/AccountManager.swift @@ -233,6 +233,18 @@ import ActivityLog return false } + public func duplicateServiceAccount(type: AccountType, endpointURL: URL?) -> Bool { + guard type != .onMyMac else { + return false + } + for account in accounts { + if account.type == type && account.endpointURL == endpointURL { + return true + } + } + return false + } + public func existingAccount(accountID: String) -> Account? { return accountsDictionary[accountID] } diff --git a/Modules/Account/Sources/Account/Miniflux/MinifluxAccountDelegate.swift b/Modules/Account/Sources/Account/Miniflux/MinifluxAccountDelegate.swift new file mode 100644 index 000000000..1c0827a31 --- /dev/null +++ b/Modules/Account/Sources/Account/Miniflux/MinifluxAccountDelegate.swift @@ -0,0 +1,1031 @@ +// +// MinifluxAccountDelegate.swift +// Account +// +// Created by Ingmar Stein on 6/18/26. +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import ActivityLog +import Articles +import ErrorLog +import RSCore +import RSParser +import RSWeb +import FeedFinder +import SyncDatabase +import os +import Secrets + +enum MinifluxAccountDelegateError: LocalizedError { + case unknown + case invalidParameter + case invalidResponse + case urlNotFound + + var errorDescription: String? { + switch self { + case .unknown: + return NSLocalizedString("An unexpected error occurred.", comment: "An unexpected error occurred.") + case .invalidParameter: + return NSLocalizedString("An invalid parameter was passed.", comment: "An invalid parameter was passed.") + case .invalidResponse: + return NSLocalizedString("There was an invalid response from the server.", comment: "Invalid response") + case .urlNotFound: + return NSLocalizedString("The API URL wasn't found.", comment: "The API URL wasn't found.") + } + } +} + +final class MinifluxAccountDelegate: AccountDelegate { + + weak var account: Account? + + private let syncDatabase: SyncDatabase + private let caller: MinifluxCaller + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Miniflux") + + var progressInfo = ProgressInfo() { + didSet { + if progressInfo != oldValue { + postProgressInfoDidChangeNotification() + } + } + } + let refreshProgress = RSProgress() + + /// Detected Miniflux server version, fetched during `accountDidInitialize`. + var minifluxVersion: MinifluxVersion? + + var behaviors: AccountBehaviors { + [.disallowFeedInMultipleFolders, .disallowFeedInRootFolder] + } + + @MainActor var server: String? { + caller.accountSettings?.endpointURL?.host + } + + var isOPMLImportInProgress = false + + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + var accountSettings: AccountSettings? { + didSet { + caller.accountSettings = accountSettings + } + } + + init(dataFolder: String) { + let databasePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") + syncDatabase = SyncDatabase(databasePath: databasePath) + + self.caller = MinifluxCaller(logger: Self.logger) + + NotificationCenter.default.addObserver(self, selector: #selector(progressInfoDidChange(_:)), name: .progressInfoDidChange, object: refreshProgress) + } + + func receiveRemoteNotification(userInfo: [AnyHashable: Any]) async { + } + + func refreshAll() async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: refreshAll") + + retrieveCredentialsIfNeeded(account) + + refreshProgress.addTasks(5) + + do { + try await account.logActivity(kind: .refreshAll) { + try await refreshAccount(account) + + try await sendArticleStatus() + refreshProgress.completeTask() + + let articleIDs = try await account.logActivity(kind: .fetchArticleIDs, detail: "All articles", successMessage: { "\($0.count) article IDs" }, { + try await caller.retrieveEntryIDs(type: .all) + }) + refreshProgress.completeTask() + + let stringArticleIDs = Set(articleIDs.map { String($0) }) + _ = await account.markAsReadAsync(articleIDs: stringArticleIDs) + try? await refreshArticleStatus() + refreshProgress.completeTask() + + await refreshMissingArticles(account) + refreshProgress.reset() + } + } catch { + Self.logger.error("MinifluxAccountDelegate: refreshAll — error \(error.localizedDescription)") + refreshProgress.reset() + throw AccountError.wrapped(error, account) + } + } + + @MainActor func syncArticleStatus() async throws -> Bool { + guard let account else { + return false + } + + Self.logger.debug("MinifluxAccountDelegate: syncArticleStatus") + + let sentCount = try await sendArticleStatusReturningCount(for: account) + let refreshChangedCount = try await refreshArticleStatusReturningCount(for: account) + return sentCount > 0 || refreshChangedCount > 0 + } + + public func sendArticleStatus() async throws { + guard let account else { + return + } + _ = try await sendArticleStatusReturningCount(for: account) + } + + private func sendArticleStatusReturningCount(for account: Account) async throws -> Int { + Self.logger.debug("MinifluxAccountDelegate: sendArticleStatus") + + return try await account.logActivity(kind: .sendArticleStatuses) { () -> Int in + let syncStatuses = (await syncDatabase.selectForProcessing()) ?? Set() + + let createUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false } + let deleteUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true } + let createStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true } + let deleteStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false } + + var sentCount = 0 + var savedError: Error? + + do { + let articleIDs = createUnreadStatuses.compactMap { Int($0.articleID) } + if !articleIDs.isEmpty { + try await caller.markEntriesUnread(entryIDs: articleIDs) + sentCount += articleIDs.count + await syncDatabase.deleteSelectedForProcessing(Set(createUnreadStatuses.map { $0.articleID })) + } + } catch { + savedError = error + } + + do { + let articleIDs = deleteUnreadStatuses.compactMap { Int($0.articleID) } + if !articleIDs.isEmpty { + try await caller.markEntriesRead(entryIDs: articleIDs) + sentCount += articleIDs.count + await syncDatabase.deleteSelectedForProcessing(Set(deleteUnreadStatuses.map { $0.articleID })) + } + } catch { + savedError = error + } + + do { + for status in createStarredStatuses { + if let entryID = Int(status.articleID) { + try await caller.toggleBookmark(entryID: entryID) + sentCount += 1 + await syncDatabase.deleteSelectedForProcessing(Set([status.articleID])) + } + } + } catch { + savedError = error + } + + do { + for status in deleteStarredStatuses { + if let entryID = Int(status.articleID) { + try await caller.toggleBookmark(entryID: entryID) + sentCount += 1 + await syncDatabase.deleteSelectedForProcessing(Set([status.articleID])) + } + } + } catch { + savedError = error + } + + if let savedError { + postSyncError(savedError, account: account, operation: "Sending article status") + throw savedError + } + return sentCount + } + } + + @MainActor func refreshArticleStatus() async throws { + guard let account else { + return + } + _ = try await refreshArticleStatusReturningCount(for: account) + } + + @MainActor private func refreshArticleStatusReturningCount(for account: Account) async throws -> Int { + Self.logger.debug("MinifluxAccountDelegate: refreshArticleStatus") + + return try await account.logActivity(kind: .refreshArticleStatuses) { () -> Int in + var changedCount = 0 + var errorOccurred = false + + let unreadIDs = try await caller.retrieveEntryIDs(type: .unread) + changedCount += await syncArticleReadState(account: account, articleIDs: unreadIDs.map { String($0) }) + + do { + let starredIDs = try await caller.retrieveEntryIDs(type: .starred) + changedCount += await syncArticleStarredState(account: account, articleIDs: starredIDs.map { String($0) }) + } catch { + errorOccurred = true + Self.logger.error("MinifluxAccountDelegate: refreshArticleStatus — retrieving starred entries failed: \(error.localizedDescription)") + } + + if errorOccurred { + let error = AccountError.unknown + postSyncError(error, account: account, operation: "Refreshing article status") + throw error + } + return changedCount + } + } + + @MainActor func importOPML(opmlFile: URL) async throws { + guard let account else { + return + } + try await account.logActivity(kind: .importOPML, detail: opmlFile.lastPathComponent) { + let opmlData = try Data(contentsOf: opmlFile) + try await caller.importOPML(opmlData: opmlData) + } + } + + @MainActor func createFolder(name: String) async throws -> Folder { + guard let account else { + throw AccountError.invalidParameter + } + Self.logger.debug("MinifluxAccountDelegate: createFolder — name \(name)") + + return try await account.logActivity(kind: .createFolder, detail: name) { + let categoryID = try await caller.createCategory(name: name) + + guard let folder = account.ensureFolder(with: name) else { + Self.logger.error("MinifluxAccountDelegate: createFolder failed — account.ensureFolder failed") + throw AccountError.invalidParameter + } + folder.externalID = String(categoryID) + return folder + } + } + + func renameFolder(with folder: Folder, to name: String) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: renameFolder — name \(folder.nameForDisplay) to \(name)") + + refreshProgress.addTask() + defer { refreshProgress.completeTask() } + + do { + try await account.logActivity(kind: .renameFolder, detail: "\(folder.name ?? "") → \(name)") { + guard let externalID = folder.externalID, let categoryID = Int(externalID) else { + throw AccountError.invalidParameter + } + try await caller.renameCategory(id: categoryID, name: name) + folder.name = name + } + } catch { + Self.logger.error("MinifluxAccountDelegate: renameFolder — error: \(error.localizedDescription)") + throw AccountError.wrapped(error, account) + } + } + + func removeFolder(with folder: Folder) async throws { + guard let account else { + return + } + try await account.logActivity(kind: .removeFolder, detail: folder.name ?? "") { + try await removeFolderImpl(for: account, with: folder) + } + } + + private func removeFolderImpl(for account: Account, with folder: Folder) async throws { + Self.logger.debug("MinifluxAccountDelegate: removeFolder — name \(folder.nameForDisplay)") + + // In Miniflux, deleting a category does not delete the feeds in it. + // The feeds become uncategorized. We should first move feeds out of the folder. + + for feed in folder.topLevelFeeds { + if let feedExternalID = feed.externalID, let feedID = Int(feedExternalID) { + refreshProgress.addTask() + + do { + // Move feed to no category by updating without category_id + try await caller.moveFeed(feedID: feedID, categoryID: 0) + account.addFeedToTreeAtTopLevel(feed) + clearFolderRelationship(for: feed, folderExternalID: folder.externalID) + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + Self.logger.error("MinifluxAccountDelegate: removeFolder — move feed error: \(error.localizedDescription)") + postSyncError(error, account: account, operation: "Removing feed from folder") + } + } + } + + if let externalID = folder.externalID, let categoryID = Int(externalID) { + try await caller.deleteCategory(id: categoryID) + } + account.removeFolderFromTree(folder) + } + + @discardableResult + func createFeed(url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { + guard let account else { + throw AccountError.invalidParameter + } + retrieveCredentialsIfNeeded(account) + + Self.logger.debug("MinifluxAccountDelegate: createFeed — url \(url) name \(name ?? "")") + + guard let url = URL(string: url) else { + throw AccountError.invalidParameter + } + + refreshProgress.addTasks(2) + + do { + return try await account.logActivity(kind: .subscribeFeed, detail: url.absoluteString) { + // For Miniflux, we can use the discover endpoint or subscribe directly. + // Try discover first, then fall back to direct subscription. + let feedURL: String + do { + let results = try await caller.discoverFeeds(url: url.absoluteString) + if let discovered = results?.first { + feedURL = discovered.url + } else { + feedURL = url.absoluteString + } + } catch { + feedURL = url.absoluteString + } + refreshProgress.completeTask() + + let categoryID: Int? + if let folder = container as? Folder, let externalID = folder.externalID, let catID = Int(externalID) { + categoryID = catID + } else { + categoryID = nil + } + + let feedID = try await caller.createFeed(url: feedURL, categoryID: categoryID) + refreshProgress.completeTask() + + // Now retrieve all feeds to find the one we just created + let feeds = try await caller.retrieveFeeds() + guard let createdFeed = feeds?.first(where: { $0.id == feedID }) else { + throw AccountError.createErrorNotFound + } + + return try await createFeed(account: account, minifluxFeed: createdFeed, name: name, container: container) + } + } catch { + Self.logger.error("MinifluxAccountDelegate: createFeed - error: \(error.localizedDescription)") + refreshProgress.reset() + throw AccountError.createErrorNotFound + } + } + + func renameFeed(with feed: Feed, to name: String) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: renameFeed — name \(feed.nameForDisplay) to name \(name)") + + guard let subscriptionID = feed.externalID, let feedID = Int(subscriptionID) else { + assert(feed.externalID != nil) + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + + do { + try await account.logActivity(kind: .renameFeed, detail: feed.url) { + try await caller.renameFeed(feedID: feedID, name: name) + feed.editedName = name + } + refreshProgress.completeTask() + } catch { + Self.logger.error("MinifluxAccountDelegate: renameFeed - error: \(error.localizedDescription)") + refreshProgress.completeTask() + throw AccountError.wrapped(error, account) + } + } + + func removeFeed(feed: Feed, container: any Container) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: removeFeed — url \(feed.url)") + + guard let subscriptionID = feed.externalID, let feedID = Int(subscriptionID) else { + assert(feed.externalID != nil) + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + defer { refreshProgress.completeTask() } + + do { + try await account.logActivity(kind: .removeFeed, detail: feed.url) { + try await caller.deleteFeed(feedID: feedID) + account.clearFeedSettings(feed) + account.removeAllInstancesOfFeedFromTreeAtAllLevels(feed) + } + } catch { + Self.logger.error("MinifluxAccountDelegate: removeFeed - error: \(error.localizedDescription)") + throw AccountError.wrapped(error, account) + } + } + + func moveFeed(feed: Feed, sourceContainer: Container, destinationContainer: Container) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: moveFeed — url \(feed.url)") + + try await account.logActivity(kind: .moveFeed, detail: feed.url) { + guard let feedExternalID = feed.externalID, let feedID = Int(feedExternalID) else { + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + defer { refreshProgress.completeTask() } + + let categoryID: Int + if let folder = destinationContainer as? Folder, let externalID = folder.externalID, let catID = Int(externalID) { + categoryID = catID + } else if destinationContainer is Account { + // Moving to root — Miniflux doesn't support root, but we can try categoryID 0 + // Actually, Miniflux feeds must have a category. If moving to root, we keep existing. + throw AccountError.invalidParameter + } else { + throw AccountError.invalidParameter + } + + do { + try await caller.moveFeed(feedID: feedID, categoryID: categoryID) + sourceContainer.removeFeedFromTreeAtTopLevel(feed) + destinationContainer.addFeedToTreeAtTopLevel(feed) + } catch { + Self.logger.error("MinifluxAccountDelegate: moveFeed - error: \(error.localizedDescription)") + throw error + } + } + } + + func addFeed(feed: Feed, container: any Container) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: addFeed — url \(feed.url)") + + try await account.logActivity(kind: .addFeed, detail: feed.url) { + if let folder = container as? Folder, let feedExternalID = feed.externalID, let feedID = Int(feedExternalID), let folderExternalID = folder.externalID, let categoryID = Int(folderExternalID) { + + refreshProgress.addTask() + + do { + try await caller.moveFeed(feedID: feedID, categoryID: categoryID) + + saveFolderRelationship(for: feed, folderExternalID: folder.externalID, feedExternalID: feedExternalID) + account.removeFeedFromTreeAtTopLevel(feed) + folder.addFeedToTreeAtTopLevel(feed) + + refreshProgress.completeTask() + + } catch { + Self.logger.error("MinifluxAccountDelegate: addFeed - error: \(error.localizedDescription)") + refreshProgress.completeTask() + throw AccountError.wrapped(error, account) + } + } else { + if let containerAccount = container as? Account { + containerAccount.addFeedIfNotInAnyFolder(feed) + } + } + } + } + + func restoreFeed(feed: Feed, container: any Container) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: restoreFeed — url \(feed.url)") + + if let existingFeed = account.existingFeed(withURL: feed.url) { + try await account.addFeed(existingFeed, container: container) + } else { + try await createFeed(url: feed.url, name: feed.editedName, container: container, validateFeed: true) + } + } + + func restoreFolder(folder: Folder) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: restoreFolder — name \(folder.nameForDisplay)") + + await account.logActivity(kind: .restoreFolder, detail: folder.name ?? "") { + for feed in folder.topLevelFeeds { + + folder.topLevelFeeds.remove(feed) + + do { + try await restoreFeed(feed: feed, container: folder) + } catch { + Self.logger.error("MinifluxAccountDelegate: restoreFolder error: \(error.localizedDescription)") + postSyncError(error, account: account, operation: "Restoring feed to folder") + } + } + + account.addFolderToTree(folder) + } + } + + @MainActor func markArticles(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool) async throws { + guard let account else { + return + } + Self.logger.debug("MinifluxAccountDelegate: markArticles — statusKey \(statusKey.rawValue)") + + let changedArticleIDs = await account.updateStatusesAsync(articleIDs: articleIDs, statusKey: statusKey, flag: flag) + let syncStatuses = Set(changedArticleIDs.map { articleID in + SyncStatus(articleID: articleID, key: SyncStatus.Key(statusKey), flag: flag) + }) + + await syncDatabase.insertStatuses(syncStatuses) + if !syncStatuses.isEmpty { + NotificationCenter.default.post(name: .AccountDidQueueArticleStatuses, object: account) + } + if let count = await syncDatabase.selectPendingCount(), count > 100 { + try? await sendArticleStatus() + } + } + + func accountDidInitialize() { + guard let account else { + return + } + retrieveCredentialsIfNeeded(account) + + Task { @MainActor in + do { + self.minifluxVersion = try await caller.retrieveVersion() + if let v = self.minifluxVersion { + Self.logger.info("MinifluxAccountDelegate: detected Miniflux version \(v.version)") + caller.supportsEntryIDsEndpoint = v.isAtLeast("2.3.2") + } + } catch { + Self.logger.warning("MinifluxAccountDelegate: unable to retrieve Miniflux version: \(error.localizedDescription)") + } + } + } + + func accountWillBeDeleted() { + } + + static func validateCredentials(credentials: Credentials, endpoint: URL?) async throws -> Credentials? { + Self.logger.debug("MinifluxAccountDelegate: validateCredentials") + + guard let endpoint else { + throw WebserviceError.noURL + } + + let caller = MinifluxCaller(logger: Self.logger) + caller.credentials = credentials + return try await caller.validateCredentials(endpoint: endpoint) + } + + func vacuumDatabases() async { + guard let account else { + return + } + await account.logActivity(kind: .vacuumDatabase, detail: AppConfig.relativeDataPath(syncDatabase.databasePath)) { + await syncDatabase.vacuum() + } + } + + // MARK: Suspend and Resume (for iOS) + + func suspendNetwork() { + Self.logger.debug("MinifluxAccountDelegate: suspendNetwork") + caller.cancelAll() + } + + func resume() { + Self.logger.debug("MinifluxAccountDelegate: resume") + if let account { + retrieveCredentialsIfNeeded(account) + } + } + + // MARK: - Notifications + + @objc func progressInfoDidChange(_ notification: Notification) { + progressInfo = refreshProgress.progressInfo + } +} + +// MARK: Private + +private extension MinifluxAccountDelegate { + + func retrieveCredentialsIfNeeded(_ account: Account) { + if credentials == nil { + credentials = try? account.retrieveCredentials(type: .minifluxAPIKey) + } + } + + @MainActor func refreshAccount(_ account: Account) async throws { + Self.logger.debug("MinifluxAccountDelegate: refreshAccount") + + do { + try await account.logActivity(kind: .refreshFeedList, successMessage: { "\($0.feeds) feeds, \($0.folders) folders" }, { () -> (folders: Int, feeds: Int) in + let categories = try await caller.retrieveCategories() + refreshProgress.completeTask() + + let feeds = try await caller.retrieveFeeds() + refreshProgress.completeTask() + + BatchUpdate.shared.perform { + self.syncFolders(account, categories) + self.syncFeeds(account, feeds) + self.syncFeedFolderRelationship(account, feeds) + } + return (folders: categories?.count ?? 0, feeds: feeds?.count ?? 0) + }) + } catch { + postSyncError(error, account: account, operation: "Refreshing account") + throw error + } + } + + func logRefreshPage(for account: Account, kind: ActivityKind, message: @escaping (T) -> String, _ fetch: () async throws -> T) async throws -> T { + try await account.logActivity(kind: kind, detail: ActivityLog.shared.nextTaskNumberString(), successMessage: message, fetch) + } + + @MainActor func syncFolders(_ account: Account, _ categories: [MinifluxCategory]?) { + Self.logger.debug("MinifluxAccountDelegate: syncFolders") + + guard let categories else { return } + assert(Thread.isMainThread) + + guard !categories.isEmpty else { return } + + let categoryExternalIDs = categories.map { String($0.id) } + + // Delete any folders not on Miniflux + if let folders = account.folders { + for folder in folders { + if !categoryExternalIDs.contains(folder.externalID ?? "") { + for feed in folder.topLevelFeeds { + account.addFeedToTreeAtTopLevel(feed) + clearFolderRelationship(for: feed, folderExternalID: folder.externalID) + } + account.removeFolderFromTree(folder) + } + } + } + + let folderExternalIDs: [String] = { + if let folders = account.folders { + return folders.compactMap { $0.externalID } + } else { + return [String]() + } + }() + + // Create any categories we don't have locally + for category in categories { + let externalID = String(category.id) + if !folderExternalIDs.contains(externalID) { + let folder = account.ensureFolder(with: category.title) + folder?.externalID = externalID + } + } + } + + @MainActor func syncFeeds(_ account: Account, _ feeds: [MinifluxFeed]?) { + Self.logger.debug("MinifluxAccountDelegate: syncFeeds — feeds.count \(feeds?.count ?? -1)") + + guard let feeds else { return } + assert(Thread.isMainThread) + + let feedIDs = feeds.map { String($0.id) } + + // Remove any feeds that are no longer in the feed list + if let folders = account.folders { + for folder in folders { + for feed in folder.topLevelFeeds { + if !feedIDs.contains(feed.feedID) { + account.clearFeedSettings(feed) + folder.removeFeedFromTreeAtTopLevel(feed) + } + } + } + } + + for feed in account.topLevelFeeds { + if !feedIDs.contains(feed.feedID) { + account.clearFeedSettings(feed) + account.removeFeedFromTreeAtTopLevel(feed) + } + } + + // Add any feeds we don't have and update any we do + for feed in feeds { + let feedID = String(feed.id) + if let existingFeed = account.existingFeed(withFeedID: feedID) { + existingFeed.name = feed.title + existingFeed.editedName = nil + existingFeed.homePageURL = feed.siteURL + } else { + let newFeed = account.createFeed(with: feed.title, url: feed.feedURL, feedID: feedID, homePageURL: feed.siteURL) + newFeed.externalID = feedID + account.addFeedToTreeAtTopLevel(newFeed) + } + } + } + + func syncFeedFolderRelationship(_ account: Account, _ feeds: [MinifluxFeed]?) { + Self.logger.debug("MinifluxAccountDelegate: syncFeedFolderRelationship — feeds.count \(feeds?.count ?? -1)") + + guard let feeds else { return } + assert(Thread.isMainThread) + + let folderDict = externalIDToFolderDictionary(with: account.folders) + + // Build category to feeds mapping + var categoryFeeds: [Int: [MinifluxFeed]] = [:] + for feed in feeds { + if let category = feed.category { + categoryFeeds[category.id, default: []].append(feed) + } + } + + // Sync folders + for (categoryID, feedList) in categoryFeeds { + let categoryIDString = String(categoryID) + guard let folder = folderDict[categoryIDString] else { continue } + let categoryFeedIDs = feedList.map { String($0.id) } + + // Remove feeds not in this category + for feed in folder.topLevelFeeds { + if !categoryFeedIDs.contains(feed.feedID) { + folder.removeFeedFromTreeAtTopLevel(feed) + clearFolderRelationship(for: feed, folderExternalID: folder.externalID) + account.addFeedToTreeAtTopLevel(feed) + } + } + + // Add feeds that should be in this category + let folderFeedIDs = folder.topLevelFeeds.map { $0.feedID } + for minifluxFeed in feedList { + let stringID = String(minifluxFeed.id) + if !folderFeedIDs.contains(stringID) { + guard let feed = account.existingFeed(withFeedID: stringID) else { + continue + } + saveFolderRelationship(for: feed, folderExternalID: categoryIDString, feedExternalID: stringID) + folder.addFeedToTreeAtTopLevel(feed) + } + } + } + + // Remove feeds from account root if they are in a category + let categorizedFeedIDs = Set(feeds.filter { $0.category != nil }.map { String($0.id) }) + for feed in account.topLevelFeeds { + if categorizedFeedIDs.contains(feed.feedID) { + account.removeFeedFromTreeAtTopLevel(feed) + } + } + } + + func externalIDToFolderDictionary(with folders: Set?) -> [String: Folder] { + guard let folders else { + return [String: Folder]() + } + + var d = [String: Folder]() + for folder in folders { + if let externalID = folder.externalID, d[externalID] == nil { + d[externalID] = folder + } + } + return d + } + + func clearFolderRelationship(for feed: Feed, folderExternalID: String?) { + Self.logger.debug("MinifluxAccountDelegate: clearFolderRelationship — \(feed.url) folderExternalID \(folderExternalID ?? "")") + + guard var folderRelationship = feed.folderRelationship, let folderExternalID else { return } + folderRelationship[folderExternalID] = nil + feed.folderRelationship = folderRelationship + } + + func saveFolderRelationship(for feed: Feed, folderExternalID: String?, feedExternalID: String) { + Self.logger.debug("MinifluxAccountDelegate: saveFolderRelationship — \(feed.url) folderExternalID \(folderExternalID ?? "") feedExternalID \(feedExternalID)") + guard let folderExternalID else { return } + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderExternalID] = feedExternalID + feed.folderRelationship = folderRelationship + } else { + feed.folderRelationship = [folderExternalID: feedExternalID] + } + } + + @MainActor func createFeed(account: Account, minifluxFeed: MinifluxFeed, name: String?, container: Container) async throws -> Feed { + Self.logger.debug("MinifluxAccountDelegate: createFeed — \(minifluxFeed.id) name \(name ?? "")") + + let feedID = String(minifluxFeed.id) + let feed = account.createFeed(with: minifluxFeed.title, url: minifluxFeed.feedURL, feedID: feedID, homePageURL: minifluxFeed.siteURL) + feed.externalID = feedID + + try await account.addFeed(feed, container: container) + if let name { + try await renameFeed(with: feed, to: name) + } + try await initialFeedDownload(account: account, feed: feed) + + return feed + } + + @discardableResult + func initialFeedDownload(account: Account, feed: Feed) async throws -> Feed { + Self.logger.debug("MinifluxAccountDelegate: initialFeedDownload — \(feed.url)") + + refreshProgress.addTasks(4) + + try await account.logActivity(kind: .refreshFeedContent(feedURL: feed.url), detail: feed.nameForDisplay) { + // Download initial articles for the feed + // We fetch recent entries and mark them as read since they're already existing + let articleIDs = try await caller.retrieveEntryIDs(type: .all) + refreshProgress.completeTask() + + let stringArticleIDs = Set(articleIDs.map { String($0) }) + _ = await account.markAsReadAsync(articleIDs: stringArticleIDs) + refreshProgress.completeTask() + + try? await refreshArticleStatus() + refreshProgress.completeTask() + + await refreshMissingArticles(account) + refreshProgress.reset() + } + + return feed + } + + func refreshMissingArticles(_ account: Account) async { + Self.logger.debug("MinifluxAccountDelegate: refreshMissingArticles") + + await account.logActivity(kind: .refreshMissingArticles) { + let fetchedArticleIDs = await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDateAsync() + + if fetchedArticleIDs.isEmpty { + return + } + + Self.logger.info("Miniflux: Refreshing missing articles") + + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 150) + let intChunks = chunkedArticleIDs.map { chunk in + chunk.compactMap { Int($0) } + } + + refreshProgress.addTasks(intChunks.count + 1) + + for chunk in intChunks { + guard !chunk.isEmpty else { + refreshProgress.completeTask() + continue + } + + do { + let entries = try await logRefreshPage(for: account, kind: .refreshMissingArticles, message: { "\($0?.count ?? 0) articles" }, { try await caller.retrieveEntries(articleIDs: chunk) }) + refreshProgress.completeTask() + await processEntries(account: account, entries: entries) + } catch { + Self.logger.error("Miniflux: Refresh missing articles error: \(error.localizedDescription)") + postSyncError(error, account: account, operation: "Refreshing missing articles") + } + } + + refreshProgress.completeTask() + Self.logger.info("Miniflux: Finished refreshing missing articles") + } + } + + func processEntries(account: Account, entries: [MinifluxEntry]?) async { + Self.logger.debug("MinifluxAccountDelegate: processEntries") + + let parsedItems = mapEntriesToParsedItems(account: account, entries: entries) + let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL }).mapValues { Set($0) } + + await account.updateAsync(feedIDsAndItems: feedIDsAndItems, defaultRead: true) + } + + func mapEntriesToParsedItems(account: Account, entries: [MinifluxEntry]?) -> Set { + Self.logger.debug("MinifluxAccountDelegate: mapEntriesToParsedItems — entries.count \(entries?.count ?? 0)") + + guard let entries else { + return Set() + } + + let parsedItems: [ParsedItem] = entries.compactMap { entry in + var authors: Set? { + guard let name = entry.author else { + return nil + } + return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)]) + } + + let datePublished: Date? = { + return ISO8601DateFormatter().date(from: entry.publishedAt) + }() + + return ParsedItem(syncServiceID: String(entry.id), + uniqueID: String(entry.id), + feedURL: String(entry.feed.id), + url: nil, + externalURL: entry.url, + title: entry.title, + language: nil, + contentHTML: entry.content, + contentText: nil, + markdown: nil, + summary: entry.content, + imageURL: nil, + bannerImageURL: nil, + datePublished: datePublished, + dateModified: nil, + authors: authors, + tags: nil, + attachments: nil) + } + + return Set(parsedItems) + } + + func syncArticleReadState(account: Account, articleIDs: [String]?) async -> Int { + Self.logger.debug("MinifluxAccountDelegate: syncArticleReadState — articleIDs.count \(articleIDs?.count ?? 0)") + + guard let articleIDs else { + return 0 + } + + let pendingArticleIDs = (await syncDatabase.selectPendingReadStatusArticleIDs()) ?? Set() + let updatableUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs) + let currentUnreadArticleIDs = await account.fetchUnreadArticleIDsAsync() + + // Mark articles as unread + let deltaUnreadArticleIDs = updatableUnreadArticleIDs.subtracting(currentUnreadArticleIDs) + let markedUnread = await account.markAsUnreadAsync(articleIDs: deltaUnreadArticleIDs) + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableUnreadArticleIDs) + let markedRead = await account.markAsReadAsync(articleIDs: deltaReadArticleIDs) + + return markedUnread.count + markedRead.count + } + + func syncArticleStarredState(account: Account, articleIDs: [String]?) async -> Int { + Self.logger.debug("MinifluxAccountDelegate: syncArticleStarredState — articleIDs.count \(articleIDs?.count ?? 0)") + + guard let articleIDs else { + return 0 + } + + let pendingArticleIDs = (await syncDatabase.selectPendingStarredStatusArticleIDs()) ?? Set() + let updatableStarredArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs) + let currentStarredArticleIDs = await account.fetchStarredArticleIDsAsync() + + // Mark articles as starred + let deltaStarredArticleIDs = updatableStarredArticleIDs.subtracting(currentStarredArticleIDs) + let markedStarred = await account.markAsStarredAsync(articleIDs: deltaStarredArticleIDs) + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableStarredArticleIDs) + let markedUnstarred = await account.markAsUnstarredAsync(articleIDs: deltaUnstarredArticleIDs) + + return markedStarred.count + markedUnstarred.count + } + + func postSyncError(_ error: Error, account: Account, operation: String, fileName: String = #fileID, functionName: String = #function, lineNumber: Int = #line) { + let errorLogUserInfo = ErrorLogUserInfoKey.userInfo(sourceName: account.nameForDisplay, sourceID: account.type.rawValue, operation: operation, errorMessage: AccountError.detailedErrorMessage(error), fileName: fileName, functionName: functionName, lineNumber: lineNumber) + NotificationCenter.default.post(name: .appDidEncounterError, object: self, userInfo: errorLogUserInfo) + } +} diff --git a/Modules/Account/Sources/Account/Miniflux/MinifluxCaller.swift b/Modules/Account/Sources/Account/Miniflux/MinifluxCaller.swift new file mode 100644 index 000000000..15b387e43 --- /dev/null +++ b/Modules/Account/Sources/Account/Miniflux/MinifluxCaller.swift @@ -0,0 +1,536 @@ +// +// MinifluxCaller.swift +// Account +// +// Created by Ingmar Stein on 6/18/26. +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os +import RSWeb +import Secrets + +@MainActor final class MinifluxCaller { + + private let session = URLSession.webservice + private let logger: Logger + + var accountSettings: AccountSettings? + var credentials: Credentials? + + /// Set to `true` when the Miniflux server is v2.3.2+ and supports `/v1/entries/ids`. + var supportsEntryIDsEndpoint = false + + // Cached category list for mapping between category IDs and names. + private var categories: [MinifluxCategory]? + + init(logger: Logger) { + self.logger = logger + } + + func cancelAll() { + session.cancelAll() + } + + // MARK: - API Base URL + + private var apiBaseURL: URL? { + guard let endpointURL = accountSettings?.endpointURL else { + return nil + } + return endpointURL.appendingPathComponent("v1") + } + + // MARK: - Authentication + + private func authenticatedRequest(url: URL, method: String? = nil, body: Data? = nil) throws -> URLRequest { + guard let credentials, credentials.type == .minifluxAPIKey else { + throw CredentialsError.missingAccessToken + } + + var request = URLRequest(url: url) + request.setValue(credentials.secret, forHTTPHeaderField: "X-Auth-Token") + + if let method { + request.httpMethod = method + } + + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + } + + return request + } + + // MARK: - Validation + + func validateCredentials(endpoint: URL) async throws -> Credentials? { + guard let credentials else { + throw CredentialsError.missingAccessToken + } + + let baseURL = endpoint.appendingPathComponent("v1") + let url = baseURL.appendingPathComponent("me") + + var request = URLRequest(url: url) + request.setValue(credentials.secret, forHTTPHeaderField: "X-Auth-Token") + + do { + let (response, _) = try await session.send(request: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + return nil + } + throw AccountError.invalidResponse + } + return credentials + } catch { + if let webserviceError = error as? WebserviceError, case .httpError(let code) = webserviceError, code == 404 { + throw AccountError.urlNotFound + } + throw error + } + } + + // MARK: - Feeds + + // MARK: - Version + + func retrieveVersion() async throws -> MinifluxVersion? { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("version") + let request = try authenticatedRequest(url: url) + + let (_, version) = try await session.send(request: request, resultType: MinifluxVersion.self) + return version + } + + + func retrieveFeeds() async throws -> [MinifluxFeed]? { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds") + let request = try authenticatedRequest(url: url) + + let (_, feeds) = try await session.send(request: request, resultType: [MinifluxFeed].self) + return feeds + } + + func createFeed(url feedURL: String, categoryID: Int?) async throws -> Int { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds") + + struct CreateFeedBody: Encodable { + let feedURL: String + let categoryID: Int? + + enum CodingKeys: String, CodingKey { + case feedURL = "feed_url" + case categoryID = "category_id" + } + } + + let body = CreateFeedBody(feedURL: feedURL, categoryID: categoryID) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "POST", body: bodyData) + + let (response, result) = try await session.send(request: request, resultType: MinifluxCreateFeedResult.self) + + guard response.statusCode == 201, let feedID = result?.feedID else { + throw AccountError.invalidResponse + } + + // Refresh the newly created feed to fetch its articles + let refreshURL = baseURL.appendingPathComponent("feeds").appendingPathComponent(String(feedID)).appendingPathComponent("refresh") + let refreshRequest = try authenticatedRequest(url: refreshURL, method: "PUT") + _ = try await session.send(request: refreshRequest) + + return feedID + } + + func deleteFeed(feedID: Int) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds").appendingPathComponent(String(feedID)) + let request = try authenticatedRequest(url: url, method: "DELETE") + + let (response, _) = try await session.send(request: request) + guard response.statusCode == 204 else { + throw AccountError.invalidResponse + } + } + + func renameFeed(feedID: Int, name: String) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds").appendingPathComponent(String(feedID)) + + struct RenameFeedBody: Encodable { + let title: String + } + + let body = RenameFeedBody(title: name) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "PUT", body: bodyData) + _ = try await session.send(request: request) + } + + func moveFeed(feedID: Int, categoryID: Int) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds").appendingPathComponent(String(feedID)) + + struct MoveFeedBody: Encodable { + let categoryID: Int + + enum CodingKeys: String, CodingKey { + case categoryID = "category_id" + } + } + + let body = MoveFeedBody(categoryID: categoryID) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "PUT", body: bodyData) + _ = try await session.send(request: request) + } + + func refreshAllFeeds() async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("feeds").appendingPathComponent("refresh") + let request = try authenticatedRequest(url: url, method: "PUT") + _ = try await session.send(request: request) + } + + // MARK: - Categories + + func retrieveCategories() async throws -> [MinifluxCategory]? { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("categories") + let request = try authenticatedRequest(url: url) + + let (_, categories) = try await session.send(request: request, resultType: [MinifluxCategory].self) + self.categories = categories + return categories + } + + func createCategory(name: String) async throws -> Int { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("categories") + + struct CreateCategoryBody: Encodable { + let title: String + } + + let body = CreateCategoryBody(title: name) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "POST", body: bodyData) + + let (response, result) = try await session.send(request: request, resultType: MinifluxCreateCategoryResult.self) + + guard response.statusCode == 201, let id = result?.id else { + throw AccountError.invalidResponse + } + + // Invalidate category cache + self.categories = nil + + return id + } + + func deleteCategory(id: Int) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("categories").appendingPathComponent(String(id)) + let request = try authenticatedRequest(url: url, method: "DELETE") + + let (response, _) = try await session.send(request: request) + guard response.statusCode == 204 else { + throw AccountError.invalidResponse + } + + self.categories = nil + } + + func renameCategory(id: Int, name: String) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("categories").appendingPathComponent(String(id)) + + struct RenameCategoryBody: Encodable { + let title: String + } + + let body = RenameCategoryBody(title: name) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "PUT", body: bodyData) + _ = try await session.send(request: request) + + self.categories = nil + } + + // MARK: - Entries + + func retrieveEntries(status: String? = nil, offset: Int = 0, limit: Int = 100) async throws -> MinifluxEntriesResult? { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + var urlComponents = URLComponents(url: baseURL.appendingPathComponent("entries"), resolvingAgainstBaseURL: false) + var queryItems = [ + URLQueryItem(name: "offset", value: String(offset)), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "direction", value: "desc"), + URLQueryItem(name: "order", value: "published_at") + ] + if let status { + queryItems.append(URLQueryItem(name: "status", value: status)) + } + urlComponents?.queryItems = queryItems + + guard let url = urlComponents?.url else { + throw WebserviceError.noURL + } + + let request = try authenticatedRequest(url: url) + let (_, result) = try await session.send(request: request, resultType: MinifluxEntriesResult.self) + return result + } + + enum EntryListType { + case all + case unread + case starred + } + + func retrieveEntryIDs(type: EntryListType = .all) async throws -> [Int] { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + // Use the more efficient /v1/entries/ids endpoint when available (v2.3.2+). + if supportsEntryIDsEndpoint { + var urlComponents = URLComponents(url: baseURL.appendingPathComponent("entries").appendingPathComponent("ids"), resolvingAgainstBaseURL: false) + var queryItems: [URLQueryItem] = [] + switch type { + case .all: + break + case .unread: + queryItems.append(URLQueryItem(name: "status", value: "unread")) + case .starred: + queryItems.append(URLQueryItem(name: "starred", value: "true")) + } + urlComponents?.queryItems = queryItems + + guard let url = urlComponents?.url else { + throw WebserviceError.noURL + } + + let request = try authenticatedRequest(url: url) + let (_, result) = try await session.send(request: request, resultType: MinifluxEntryIDsResult.self) + return result?.entryIDs ?? [] + } + + // Fallback: paginate through /v1/entries (available since v2.0). + var allIDs: [Int] = [] + var offset = 0 + let pageSize = 1000 + + while true { + var urlComponents = URLComponents(url: baseURL.appendingPathComponent("entries"), resolvingAgainstBaseURL: false) + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "offset", value: String(offset)), + URLQueryItem(name: "limit", value: String(pageSize)), + URLQueryItem(name: "direction", value: "asc") + ] + switch type { + case .all: + break + case .unread: + queryItems.append(URLQueryItem(name: "status", value: "unread")) + case .starred: + queryItems.append(URLQueryItem(name: "starred", value: "true")) + } + urlComponents?.queryItems = queryItems + + guard let url = urlComponents?.url else { + throw WebserviceError.noURL + } + + let request = try authenticatedRequest(url: url) + let (_, result) = try await session.send(request: request, resultType: MinifluxEntriesResult.self) + + guard let result else { + return allIDs + } + + let pageIDs = result.entries.map { $0.id } + allIDs.append(contentsOf: pageIDs) + + if pageIDs.count < pageSize || result.total <= allIDs.count { + return allIDs + } + + offset += pageSize + } + } + + func retrieveEntries(articleIDs: [Int]) async throws -> [MinifluxEntry]? { + guard !articleIDs.isEmpty else { + return [] + } + + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + // Fetch entries in batches. The entries endpoint supports filtering + // but for individual IDs we fetch them one at a time up to a batch limit. + // We use the "after_entry_id" approach for efficiency. + // For simplicity, fetch entries one by one and collect. + var entries: [MinifluxEntry] = [] + + // Fetch entries in chunks using a query approach + for idBatch in articleIDs.chunked(into: 100) { + var urlComponents = URLComponents(url: baseURL.appendingPathComponent("entries"), resolvingAgainstBaseURL: false) + + // Build query with status filter to get all then filter locally. + // Miniflux doesn't have a direct "get by IDs" endpoint, + // but we can use the search parameter with IDs or fetch individually. + // For efficiency, we fetch by searching for individual entries. + let queryItems = [ + URLQueryItem(name: "limit", value: String(idBatch.count * 2)), // extra margin + URLQueryItem(name: "direction", value: "desc") + ] + urlComponents?.queryItems = queryItems + + guard let url = urlComponents?.url else { + throw WebserviceError.noURL + } + + let request = try authenticatedRequest(url: url) + let (_, result) = try await session.send(request: request, resultType: MinifluxEntriesResult.self) + + if let fetchedEntries = result?.entries { + let matchingEntries = fetchedEntries.filter { idBatch.contains($0.id) } + entries.append(contentsOf: matchingEntries) + } + } + + return entries + } + + func markEntriesRead(entryIDs: [Int]) async throws { + try await updateEntryStatus(entryIDs: entryIDs, status: "read") + } + + func markEntriesUnread(entryIDs: [Int]) async throws { + try await updateEntryStatus(entryIDs: entryIDs, status: "unread") + } + + private func updateEntryStatus(entryIDs: [Int], status: String) async throws { + guard !entryIDs.isEmpty else { + return + } + + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("entries") + let body = MinifluxBatchEntryUpdate(entryIDs: entryIDs, status: status) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: url, method: "PUT", body: bodyData) + let (response, _) = try await session.send(request: request) + guard response.statusCode == 204 else { + throw AccountError.invalidResponse + } + } + + func toggleBookmark(entryID: Int) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("entries").appendingPathComponent(String(entryID)).appendingPathComponent("bookmark") + let request = try authenticatedRequest(url: url, method: "PUT") + let (response, _) = try await session.send(request: request) + guard response.statusCode == 204 else { + throw AccountError.invalidResponse + } + } + + // MARK: - OPML + + func importOPML(opmlData: Data) async throws { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let url = baseURL.appendingPathComponent("import") + var request = try authenticatedRequest(url: url, method: "POST", body: opmlData) + request.setValue("text/xml", forHTTPHeaderField: "Content-Type") + + let (response, _) = try await session.send(request: request) + guard response.statusCode == 201 else { + throw AccountError.invalidResponse + } + } + + // MARK: - Discover + + func discoverFeeds(url: String, username: String? = nil, password: String? = nil) async throws -> [MinifluxDiscoverResult]? { + guard let baseURL = apiBaseURL else { + throw CredentialsError.missingEndpointURL + } + + let apiURL = baseURL.appendingPathComponent("discover") + + struct DiscoverBody: Encodable { + let url: String + let username: String? + let password: String? + } + + let body = DiscoverBody(url: url, username: username, password: password) + let bodyData = try JSONEncoder().encode(body) + + let request = try authenticatedRequest(url: apiURL, method: "POST", body: bodyData) + let (_, results) = try await session.send(request: request, resultType: [MinifluxDiscoverResult].self) + return results + } +} diff --git a/Modules/Account/Sources/Account/Miniflux/MinifluxModels.swift b/Modules/Account/Sources/Account/Miniflux/MinifluxModels.swift new file mode 100644 index 000000000..c77e93ce9 --- /dev/null +++ b/Modules/Account/Sources/Account/Miniflux/MinifluxModels.swift @@ -0,0 +1,290 @@ +// +// MinifluxModels.swift +// Account +// +// Created by Ingmar Stein on 6/18/26. +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// MARK: - Feeds + +struct MinifluxFeed: Decodable, Sendable { + let id: Int + let title: String + let feedURL: String + let siteURL: String? + let category: MinifluxCategory? + let icon: MinifluxFeedIcon? + let crawler: Bool? + let disabled: Bool? + let fetchViaProxy: Bool? + let scraperRules: String? + let rewriteRules: String? + let blocklistRules: String? + let keeplistRules: String? + let userAgent: String? + let username: String? + let password: String? + let ignoreHTTPCache: Bool? + let checkedAt: String? + let etagHeader: String? + let lastModifiedHeader: String? + let parsingErrorCount: Int? + let parsingErrorMessage: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case feedURL = "feed_url" + case siteURL = "site_url" + case category + case icon + case crawler + case disabled + case fetchViaProxy = "fetch_via_proxy" + case scraperRules = "scraper_rules" + case rewriteRules = "rewrite_rules" + case blocklistRules = "blocklist_rules" + case keeplistRules = "keeplist_rules" + case userAgent = "user_agent" + case username + case password + case ignoreHTTPCache = "ignore_http_cache" + case checkedAt = "checked_at" + case etagHeader = "etag_header" + case lastModifiedHeader = "last_modified_header" + case parsingErrorCount = "parsing_error_count" + case parsingErrorMessage = "parsing_error_message" + } +} + +struct MinifluxFeedIcon: Decodable, Sendable { + let feedID: Int + let iconID: Int + + enum CodingKeys: String, CodingKey { + case feedID = "feed_id" + case iconID = "icon_id" + } +} + +struct MinifluxCreateFeedResult: Decodable, Sendable { + let feedID: Int + + enum CodingKeys: String, CodingKey { + case feedID = "feed_id" + } +} + +// MARK: - Categories + +struct MinifluxCategory: Decodable, Sendable { + let id: Int + let title: String + let userID: Int? + let hideGlobally: Bool? + let totalUnread: Int? + let feedCount: Int? + + enum CodingKeys: String, CodingKey { + case id + case title + case userID = "user_id" + case hideGlobally = "hide_globally" + case totalUnread = "total_unread" + case feedCount = "feed_count" + } +} + +struct MinifluxCreateCategoryResult: Decodable, Sendable { + let id: Int +} + +// MARK: - Entries + +struct MinifluxEntry: Decodable, Sendable { + let id: Int + let userID: Int? + let title: String + let url: String? + let content: String + let author: String? + let publishedAt: String + let createdAt: String + let status: String + let starred: Bool? + let readingTime: Int? + let hash: String? + let shareCode: String? + let enclosures: [MinifluxEnclosure]? + let feed: MinifluxEntryFeed + let tags: [String]? + + enum CodingKeys: String, CodingKey { + case id + case userID = "user_id" + case title + case url + case content + case author + case publishedAt = "published_at" + case createdAt = "created_at" + case status + case starred + case readingTime = "reading_time" + case hash + case shareCode = "share_code" + case enclosures + case feed + case tags + } +} + +struct MinifluxEntryFeed: Decodable, Sendable { + let id: Int + let title: String + let feedURL: String? + let siteURL: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case feedURL = "feed_url" + case siteURL = "site_url" + } +} + +struct MinifluxEnclosure: Decodable, Sendable { + let id: Int + let userID: Int + let entryID: Int + let url: String + let mimeType: String + let size: Int? + + enum CodingKeys: String, CodingKey { + case id + case userID = "user_id" + case entryID = "entry_id" + case url + case mimeType = "mime_type" + case size + } +} + +struct MinifluxEntriesResult: Decodable, Sendable { + let total: Int + let entries: [MinifluxEntry] +} + +// MARK: - Version + +struct MinifluxVersion: Decodable, Sendable { + let version: String + let commit: String? + let buildDate: String? + + enum CodingKeys: String, CodingKey { + case version + case commit + case buildDate = "build_date" + } + + /// Compares this version against a minimum required version string like "2.3.2". + func isAtLeast(_ minVersion: String) -> Bool { + let this = version.split(separator: ".").compactMap { Int($0) } + let other = minVersion.split(separator: ".").compactMap { Int($0) } + let maxLen = max(this.count, other.count) + for i in 0.. b { return true } + } + return true + } +} + +// MARK: - Entry IDs (v2.3.2+) + +struct MinifluxEntryIDsResult: Decodable, Sendable { + let total: Int + let entryIDs: [Int] + + enum CodingKeys: String, CodingKey { + case total + case entryIDs = "entry_ids" + } +} + +// MARK: - User + +struct MinifluxUser: Decodable, Sendable { + let id: Int + let username: String + let isAdmin: Bool? + let theme: String? + let language: String? + let timezone: String? + let entryDirection: String? + let entriesPerPage: Int? + let keyboardShortcuts: Bool? + let showReadingTime: Bool? + let entrySwipe: Bool? + let gestureNav: Bool? + let externalFontHosts: String? + let stylesheet: String? + let googleReader: Bool? + let doubleTap: Bool? + + enum CodingKeys: String, CodingKey { + case id + case username + case isAdmin = "is_admin" + case theme + case language + case timezone + case entryDirection = "entry_direction" + case entriesPerPage = "entries_per_page" + case keyboardShortcuts = "keyboard_shortcuts" + case showReadingTime = "show_reading_time" + case entrySwipe = "entry_swipe" + case gestureNav = "gesture_nav" + case externalFontHosts = "external_font_hosts" + case stylesheet + case googleReader = "google_reader" + case doubleTap = "double_tap" + } +} + +// MARK: - Discover + +struct MinifluxDiscoverResult: Decodable, Sendable { + let url: String + let title: String + let type: String +} + +// MARK: - Batch Entry Update + +struct MinifluxBatchEntryUpdate: Encodable, Sendable { + let entryIDs: [Int] + let status: String + + enum CodingKeys: String, CodingKey { + case entryIDs = "entry_ids" + case status + } +} + +// MARK: - Error + +struct MinifluxError: Decodable, Sendable { + let errorMessage: String + + enum CodingKeys: String, CodingKey { + case errorMessage = "error_message" + } +} diff --git a/Modules/Account/Sources/Account/URLRequest+Account.swift b/Modules/Account/Sources/Account/URLRequest+Account.swift index f222c0e46..e055df430 100755 --- a/Modules/Account/Sources/Account/URLRequest+Account.swift +++ b/Modules/Account/Sources/Account/URLRequest+Account.swift @@ -50,6 +50,8 @@ public extension URLRequest { case .readerAPIKey: let auth = "GoogleLogin auth=\(credentials.secret)" setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .minifluxAPIKey: + setValue(credentials.secret, forHTTPHeaderField: "X-Auth-Token") case .oauthAccessToken: let auth = "OAuth \(credentials.secret)" setValue(auth, forHTTPHeaderField: "Authorization") diff --git a/Modules/Secrets/Sources/Secrets/Credentials.swift b/Modules/Secrets/Sources/Secrets/Credentials.swift index 47c3002a7..7a0b41066 100644 --- a/Modules/Secrets/Sources/Secrets/Credentials.swift +++ b/Modules/Secrets/Sources/Secrets/Credentials.swift @@ -58,6 +58,7 @@ public enum CredentialsType: String, Sendable { case newsBlurSessionID = "newsBlurSessionId" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" + case minifluxAPIKey = "minifluxAPIKey" case oauthAccessToken = "oauthAccessToken" case oauthAccessTokenSecret = "oauthAccessTokenSecret" case oauthRefreshToken = "oauthRefreshToken" diff --git a/Shared/AccountType+Helpers.swift b/Shared/AccountType+Helpers.swift index 632d9f861..2b2007b7a 100644 --- a/Shared/AccountType+Helpers.swift +++ b/Shared/AccountType+Helpers.swift @@ -40,6 +40,8 @@ extension AccountType { return .systemIndigo case .theOldReader: return .systemPink + case .miniflux: + return .systemMint } } #else @@ -61,6 +63,8 @@ extension AccountType { return .brown case .bazQux: return .indigo + case .miniflux: + return .mint case .theOldReader: return .pink } @@ -95,6 +99,8 @@ extension AccountType { return Image("accountInoreader") case .newsBlur: return Image("accountNewsBlur") + case .miniflux: + return Image("accountMiniflux") case .theOldReader: return Image("accountTheOldReader") } diff --git a/Shared/Assets.swift b/Shared/Assets.swift index a17c3d4a6..3ecb8ca9a 100644 --- a/Shared/Assets.swift +++ b/Shared/Assets.swift @@ -30,6 +30,7 @@ struct Assets { static var accountFeedly: RSImage { RSImage(named: "accountFeedly")! } static var accountFreshRSS: RSImage { RSImage(named: "accountFreshRSS")! } static var accountInoreader: RSImage { RSImage(named: "accountInoreader")! } + static var accountMiniflux: RSImage { RSImage(named: "accountMiniflux")! } static var accountNewsBlur: RSImage { RSImage(named: "accountNewsBlur")! } static var accountTheOldReader: RSImage { RSImage(named: "accountTheOldReader")! } @@ -153,6 +154,8 @@ struct Assets { return Assets.Images.accountFeedly case .freshRSS: return Assets.Images.accountFreshRSS + case .miniflux: + return Assets.Images.accountMiniflux case .inoreader: return Assets.Images.accountInoreader case .newsBlur: diff --git a/Shared/Localizable.xcstrings b/Shared/Localizable.xcstrings index fa805319e..2e259a2cc 100644 --- a/Shared/Localizable.xcstrings +++ b/Shared/Localizable.xcstrings @@ -109,9 +109,15 @@ "Always Use Reader View" : { "comment" : "Always Use Reader View" }, + "API URL and API Key are required." : { + "comment" : "Credentials Error" + }, "API URL: https://fresh.rss.net/api/greader.php" : { "comment" : "FreshRSS API Helper" }, + "API URL: https://miniflux.example.com" : { + "comment" : "Miniflux API URL Helper" + }, "Are you sure you want to delete %d feeds?" : { "comment" : "Delete feeds alert message" }, @@ -322,6 +328,9 @@ "Don’t have a FreshRSS instance?" : { "comment" : "No FreshRSS" }, + "Don't have a Miniflux instance?" : { + "comment" : "No Miniflux" + }, "Don’t have a The Old Reader account?" : { "comment" : "No OldReader" }, @@ -428,7 +437,7 @@ "comment" : "Find in Article" }, "Find out more" : { - "comment" : "No FreshRSS Button" + "comment" : "No FreshRSS Button\nNo Miniflux Button" }, "Find Out More" : { "comment" : "Find Out More" @@ -496,6 +505,9 @@ "https://fresh.rss.net/api/greader.php" : { "comment" : "FreshRSS API Helper" }, + "https://miniflux.example.com" : { + "comment" : "Miniflux API URL Helper" + }, "iCloud" : { "comment" : "iCloud Account" }, @@ -534,6 +546,9 @@ } } }, + "Invalid API key." : { + "comment" : "Credentials Error" + }, "Invalid API URL." : { "comment" : "Invalid API URL" }, @@ -932,6 +947,12 @@ "Sign in to your Inoreader account." : { "comment" : "Inoreader" }, + "Sign in to your Miniflux instance and sync your feeds across your devices. Your API key will be encrypted and stored in Keychain.\n\nDon't have a Miniflux instance?" : { + "comment" : "Miniflux" + }, + "Sign in to your Miniflux instance." : { + "comment" : "Miniflux" + }, "Sign in to your NewsBlur account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a NewsBlur account?" : { "comment" : "NewsBlur" }, @@ -987,6 +1008,9 @@ "comment" : "A list of credits for the app.", "isCommentAutoGenerated" : true }, + "The API URL couldn't be found. Please check the URL." : { + "comment" : "API URL not found" + }, "The name can be anything you want. You can even use emoji. 🎸" : { }, @@ -1005,6 +1029,9 @@ "There is already a Feedbin account with that username created." : { "comment" : "Duplicate Error" }, + "There is already a Miniflux account with that URL created." : { + "comment" : "Duplicate Error" + }, "There is already a NewsBlur account with that username created." : { "comment" : "Duplicate Error" }, diff --git a/iOS/Account/MinifluxAccountViewController.swift b/iOS/Account/MinifluxAccountViewController.swift new file mode 100644 index 000000000..6159a6fd9 --- /dev/null +++ b/iOS/Account/MinifluxAccountViewController.swift @@ -0,0 +1,232 @@ +// +// MinifluxAccountViewController.swift +// NetNewsWire-iOS +// +// Created by Ingmar Stein on 6/18/26. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import UIKit +import SafariServices +import RSCore +import RSWeb +import Account +import Secrets + +final class MinifluxAccountViewController: UITableViewController { + + @IBOutlet var activityIndicator: UIActivityIndicatorView! + @IBOutlet var cancelBarButtonItem: UIBarButtonItem! + @IBOutlet var apiURLTextField: UITextField! + @IBOutlet var apiKeyTextField: UITextField! + @IBOutlet var showHideButton: UIButton! + @IBOutlet var actionButton: UIButton! + @IBOutlet var footerLabel: UILabel! + @IBOutlet var signUpButton: UIButton! + + weak var account: Account? + var accountType: AccountType? + weak var delegate: AddAccountDismissDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + setupFooter() + + activityIndicator.isHidden = true + apiURLTextField.delegate = self + apiKeyTextField.delegate = self + + title = "Miniflux" + + if let unwrappedAccount = account, + let credentials = try? retrieveCredentialsForAccount(for: unwrappedAccount) { + actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) + actionButton.isEnabled = true + apiURLTextField.text = unwrappedAccount.endpointURL?.absoluteString ?? "" + apiKeyTextField.text = credentials.secret + } else { + actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) + } + + apiURLTextField.placeholder = NSLocalizedString("API URL: https://miniflux.example.com", comment: "Miniflux API URL Helper") + + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: apiURLTextField) + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: apiKeyTextField) + + tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") + } + + private func setupFooter() { + footerLabel.text = NSLocalizedString("Sign in to your Miniflux instance and sync your feeds across your devices. Your API key will be encrypted and stored in Keychain.\n\nDon't have a Miniflux instance?", comment: "Miniflux") + signUpButton.setTitle(NSLocalizedString("Find Out More", comment: "Find Out More"), for: .normal) + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + if section == 0 { + let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView + headerView.imageView.image = headerViewImage() + return headerView + } else { + return super.tableView(tableView, viewForHeaderInSection: section) + } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: + return 2 + default: + return 1 + } + } + + @IBAction func cancel(_ sender: Any) { + dismiss(animated: true, completion: nil) + } + + @IBAction func showHidePassword(_ sender: Any) { + if apiKeyTextField.isSecureTextEntry { + apiKeyTextField.isSecureTextEntry = false + showHideButton.setTitle("Hide", for: .normal) + } else { + apiKeyTextField.isSecureTextEntry = true + showHideButton.setTitle("Show", for: .normal) + } + } + + @IBAction func action(_ sender: Any) { + guard validateDataEntry() else { + return + } + + let apiKey = apiKeyTextField.text! + let url = apiURL()! + + let trimmedAPIKey = apiKey.trimmingWhitespace + + guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: .miniflux, endpointURL: url) else { + showError(NSLocalizedString("There is already a Miniflux account with that URL created.", comment: "Duplicate Error")) + return + } + + Task { @MainActor in + startAnimatingActivityIndicator() + disableNavigation() + + @MainActor func stopAnimation() { + stopAnimatingActivityIndicator() + enableNavigation() + } + + let credentials = Credentials(type: .minifluxAPIKey, username: trimmedAPIKey, secret: trimmedAPIKey) + do { + let validatedCredentials = try await Account.validateCredentials(type: .miniflux, credentials: credentials, endpoint: url) + stopAnimation() + + if let validatedCredentials { + if account == nil { + account = AccountManager.shared.createAccount(type: .miniflux) + } + + do { + account?.endpointURL = url + + try account?.storeCredentials(validatedCredentials) + + dismiss(animated: true, completion: nil) + + do { + try await account?.refreshAll() + } catch { + showError(NSLocalizedString(error.localizedDescription, comment: "Account Refresh Error")) + } + + delegate?.dismiss() + } catch { + showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) + } + } else { + showError(NSLocalizedString("Invalid API key.", comment: "Credentials Error")) + } + } catch { + stopAnimation() + if case AccountError.urlNotFound = error { + showError(NSLocalizedString("The API URL couldn't be found. Please check the URL.", comment: "API URL not found")) + } else { + showError(error.localizedDescription) + } + } + } + } + + private func retrieveCredentialsForAccount(for account: Account) throws -> Credentials? { + try account.retrieveCredentials(type: .minifluxAPIKey) + } + + private func headerViewImage() -> UIImage? { + Assets.Images.accountMiniflux + } + + private func validateDataEntry() -> Bool { + if !apiURLTextField.hasText || !apiKeyTextField.hasText { + showError(NSLocalizedString("API URL and API Key are required.", comment: "Credentials Error")) + return false + } + guard URL(string: apiURLTextField.text!) != nil else { + showError(NSLocalizedString("Invalid API URL.", comment: "Invalid API URL")) + return false + } + return true + } + + @IBAction func signUpWithProvider(_ sender: Any) { + let url = URL(string: "https://miniflux.app")! + let safari = SFSafariViewController(url: url) + safari.modalPresentationStyle = .currentContext + self.present(safari, animated: true, completion: nil) + } + + private func apiURL() -> URL? { + URL(string: apiURLTextField.text!.trimmingWhitespace)! + } + + @objc func textDidChange(_ note: Notification) { + actionButton.isEnabled = !(apiURLTextField.text?.isEmpty ?? false) + } + + private func showError(_ message: String) { + presentError(title: "Error", message: message) + } + + private func enableNavigation() { + self.cancelBarButtonItem.isEnabled = true + self.actionButton.isEnabled = true + } + + private func disableNavigation() { + cancelBarButtonItem.isEnabled = false + actionButton.isEnabled = false + } + + private func startAnimatingActivityIndicator() { + activityIndicator.isHidden = false + activityIndicator.startAnimating() + } + + private func stopAnimatingActivityIndicator() { + self.activityIndicator.isHidden = true + self.activityIndicator.stopAnimating() + } +} + +extension MinifluxAccountViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index c7d0e5de6..7f3b95057 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -88,6 +88,13 @@ final class AccountInspectorViewController: UITableViewController { addViewController.account = account navController.modalPresentationStyle = .currentContext present(navController, animated: true) + case .miniflux: + let addViewController = MinifluxAccountViewController(style: .grouped) + addViewController.accountType = account.type + addViewController.account = account + let navController = UINavigationController(rootViewController: addViewController) + navController.modalPresentationStyle = .currentContext + present(navController, animated: true) default: break } diff --git a/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json new file mode 100644 index 000000000..ebc32f28a --- /dev/null +++ b/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "accountMiniflux.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf b/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf new file mode 100644 index 000000000..225fc2cef Binary files /dev/null and b/iOS/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf differ diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index d1b467b9c..c30a74825 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -203,6 +203,13 @@ final class AddAccountViewController: UITableViewController, AddAccountDismissDe let addViewController = navController.topViewController as! NewsBlurAccountViewController addViewController.delegate = self present(navController, animated: true) + case .miniflux: + let addViewController = MinifluxAccountViewController(style: .grouped) + addViewController.accountType = accountType + addViewController.delegate = self + let navController = UINavigationController(rootViewController: addViewController) + navController.modalPresentationStyle = .currentContext + present(navController, animated: true) case .bazQux, .inoreader, .freshRSS, .theOldReader: let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "ReaderAPIAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext