From 3486fce800ad1aaaefdc39682ab21cea670a29ac Mon Sep 17 00:00:00 2001
From: Ingmar Stein <490610+IngmarStein@users.noreply.github.com>
Date: Thu, 18 Jun 2026 23:23:38 +0200
Subject: [PATCH] Add native Miniflux account type with API key authentication
Uses Miniflux's native REST API (/v1/*) instead of the Google Reader
compatibility layer. Authenticates via X-Auth-Token header.
- New AccountType.miniflux (24) with dedicated MinifluxAccountDelegate
- MinifluxCaller: full API client for feeds, categories, entries, OPML
- Version detection (GET /v1/version) gates /v1/entries/ids for v2.3.2+
with transparent fallback to /v1/entries pagination on older instances
- macOS: credential sheet with API URL + API key fields
- iOS: credential view controller with same
- Asset catalogs: accountMiniflux icon in both Mac and iOS
Closes #3593
Closes #4743
---
.../AccountsDetailViewController.swift | 7 +
Mac/Preferences/Accounts/AccountsMiniflux.xib | 235 ++++
.../AccountsMinifluxWindowController.swift | 149 +++
.../AccountsPreferencesViewController.swift | 5 +
.../Accounts/AddAccountsView.swift | 2 +-
.../accountMiniflux.imageset/Contents.json | 16 +
.../accountMiniflux.pdf | Bin 0 -> 12063 bytes
Mac/Scripting/Account+Scriptability.swift | 2 +
Modules/Account/Sources/Account/Account.swift | 7 +
.../Sources/Account/AccountManager.swift | 12 +
.../Miniflux/MinifluxAccountDelegate.swift | 1031 +++++++++++++++++
.../Account/Miniflux/MinifluxCaller.swift | 536 +++++++++
.../Account/Miniflux/MinifluxModels.swift | 290 +++++
.../Sources/Account/URLRequest+Account.swift | 2 +
.../Secrets/Sources/Secrets/Credentials.swift | 1 +
Shared/AccountType+Helpers.swift | 6 +
Shared/Assets.swift | 3 +
Shared/Localizable.xcstrings | 29 +-
.../MinifluxAccountViewController.swift | 232 ++++
.../AccountInspectorViewController.swift | 7 +
.../accountMiniflux.imageset/Contents.json | 16 +
.../accountMiniflux.pdf | Bin 0 -> 12063 bytes
iOS/Settings/AddAccountViewController.swift | 7 +
23 files changed, 2593 insertions(+), 2 deletions(-)
create mode 100644 Mac/Preferences/Accounts/AccountsMiniflux.xib
create mode 100644 Mac/Preferences/Accounts/AccountsMinifluxWindowController.swift
create mode 100644 Mac/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json
create mode 100644 Mac/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf
create mode 100644 Modules/Account/Sources/Account/Miniflux/MinifluxAccountDelegate.swift
create mode 100644 Modules/Account/Sources/Account/Miniflux/MinifluxCaller.swift
create mode 100644 Modules/Account/Sources/Account/Miniflux/MinifluxModels.swift
create mode 100644 iOS/Account/MinifluxAccountViewController.swift
create mode 100644 iOS/Resources/Assets.xcassets/accountMiniflux.imageset/Contents.json
create mode 100644 iOS/Resources/Assets.xcassets/accountMiniflux.imageset/accountMiniflux.pdf
diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift
index 2c3b4a6ee0..73906ec094 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 0000000000..a7fa999e92
--- /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 0000000000..5da3a935a4
--- /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 2baaa8a2e5..e057aff645 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 f14d008275..83c67b2430 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 0000000000..ebc32f28a7
--- /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 0000000000000000000000000000000000000000..225fc2cef3ca3de6c0002c2a92281e0e088d1417
GIT binary patch
literal 12063
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
L85pEhWN`l%)URH#
literal 0
HcmV?d00001
diff --git a/Mac/Scripting/Account+Scriptability.swift b/Mac/Scripting/Account+Scriptability.swift
index 11116ad842..3ea02da066 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 ba5b877d28..a2dd9e5714 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 e8f6a0ab6a..e5ec00df26 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 0000000000..1c0827a312
--- /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 0000000000..15b387e430
--- /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 0000000000..c77e93ce9d
--- /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 f222c0e466..e055df4303 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 47c3002a79..7a0b41066a 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 632d9f8619..2b2007b7ac 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 96380ff202..455ad73c5f 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")! }
@@ -199,6 +200,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 17519a2fa6..5de61c249c 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"
},
@@ -431,7 +440,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"
@@ -499,6 +508,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"
},
@@ -537,6 +549,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. 🎸" : {
},
@@ -1008,6 +1032,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 0000000000..6159a6fd9e
--- /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 c7d0e5de61..7f3b950576 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 0000000000..ebc32f28a7
--- /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 0000000000000000000000000000000000000000..225fc2cef3ca3de6c0002c2a92281e0e088d1417
GIT binary patch
literal 12063
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
L85pEhWN`l%)URH#
literal 0
HcmV?d00001
diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift
index ee173a90f2..e775ef08f4 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