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