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 zcmeI2cT|(xw&)c>0Yy4U5h6$jLoZSyy-Dw&^Z=nnLa$L2P^xqR>C$^wT0nZQ(u?#i zy+iVXTlYD4?{n_EW4!U+{o~z?EWVGq=A3KJx$*#IpehV%rU7;W@c$`M0-HljB%p2p15UJzADtfWA3v|6fk9vVyD6x>6MB6| z0PmG2bmmshcBTNXtIDYVQ}kD}zoUN&wzqV$0`UD-add)#P3&+TaQ|5JAIo3enc3m~ z3|$I}tA=}3#$VdM3VP+_$}bP!?-E~SbR{tW0MXk70BVBWoUXD*Z;w2>vcK#Eerchr z^pD-SD$nmA|Ja>h1;hnvIGZ~C+|!?qaDmDucIXX8*Ay2h1F>}i!_b=tl(98&0!x9- zpyuGKJ=B4iqtoQ(Wj)M6al1;%Ecl`lCv~Uo{i@sgI8MR~6hQwh~=+H9F4PufCKYWzOHV19(L(^T8&HHml7oV zLnh+6tmB#E{n#-4Q|@FQ#~C3WCVYCPTi5>cR|5*;vw+uzwine7CY`*Yc!?}N19-2> zzDwYmteUpkR!-(jp3b(|Fm(C@iq=dd zoOH6;D%E?rPRgg5{@jTQ=Ec~&7o*ccf|PMBfOQSLRj8S~Q?6L}SGxBdX^Zw>uDSV_ zriPciTYI0{bQ4d2XSCv^aZfgnkX{x~>#G%upS_*gHMMNQW1$-6HHzZ; z(rA**wA_-s$uWloS^PN8foa2@rn0c0G{#8f&Cqi~*%3>R@%Z3m!4A*Tu%cvCRL&4a zW*_z))4q><28yM1r3x?PvKKxUzj9ph>!YnOo@+n`kxfclQLqwslp0}R$U{0lXAsA7 z(DE9$bhc+}h38ow8}Z0I?`am&4Nni-ri6&RGHDn-fUPY|K=UWQx7ftuC5XBvMnj!~ zcjz&6FoU=(2lzr_K4g#A6wMk}nl5Da&{s9vABIg*#Oc z+3oLds@^}c4V!RUwAw3z7=&dwf=0+KsL2;p%q&8OD2id?!{O}7o5T)Gjux1iWsdt8 zju)m?!DF=osNw9>m~i2n`9cK&gN!%mnT~m8EUTc8P5EF3`U;IvU(*`qryN^&GEXz2 zqC!QW(T9%1t*;zgDJa?j!JZn6QqFz~IwNIAxGoh2wb=;!MT^=3bvn&#J?UdF9(%6xir&fjRy`)q-sF8ENIB)`*;KPH|?$^hD*_pf1BE-IH&FH2jEW zxV7?u&?6aUfvTlX$=iLm{O>6`lM~z#G}Mn%6gdyUyDz$1RZJRL7SEIGr@j{l3QZem za=u7($;%-fWW>I~&J-e0$*7P$fmtn)EEO;p!({X6E~%CU+jd1OC8jMLY_Nr6X+fKO zEP3L)m0-^R{UXNkmhBLF+D%&o;cndQt6{jl@(F5nI%2RDxmkf7q3_nmE&}>;?SUrq z;_{}==x%@Wkq%elFmnIfSAdJ;4PjRcZ#{;pY8gNfC@H1rg-36vHwRejS^Y51S4u;N zXZ7yBA!4az4*r5^V?DbA{(R1~)qU*QaQq{SFJS zPMZ)0{pqmF&_d-wmj{IP>|^YH?=vj5QIQWMCox|+TaJE)n0_ORmEJG4$*7-eIoGHl zs`bXjNXtO%gBrRDM1^3eRLwzhtOUknK`(LGGn;7kkcFEWen6BwO%uq?$_>Xr<`N&t z>+Sc}Gc3ZgGoGGAJEk>E`O=B5x$J|!wbCgPukAl}EbuW%5k4J$nmWfrN7Rxu*PGn$53n?h1z5b@n zRb^TGw)rSIc4wv1nK@Sn_Z>Z++~V3?{nm+t$GnUJ;OWQ`q9Egd1*|+X?r9_Fg@$yH z8Zhe?*~)D;a4cN0cPQxq|2BE4|N3_xcyC1A_H6j5h2()a@V{Y>pSs{^sSDkzAwts7VJ`U;}c=V)|wBk)!pCt@Lb?)n$!dLD;YdvlSV9kO#h2c?c-+Cu6eP{_ys4^sV z)Oc%ZH}$!3zGy7QBPx9AJ!p0Oor->QQQrqK=Z(WF1zgCt}>ZOpD~V$VGT( zvgL|Or657&e(`wEWu9`{>1gwM?xd>VyQZU0((q^Dc{~%P`%OBu*q(#tE+*6MIdWjG z%)K#5XFQ?cp7*0QAi><-!f(#Za~NW=$0mWfb0aI?daJ+D@H;`~8yN`gK^*pHc^QFW zoWPyrWL!l}*fi&CmH3WWd3A2SiT-pBn@QJ?X~D6!v4Y*umV!zVnoe7dxC`#FC%GKr z8_k*dBIRP|*|E5?`DZQX-4m)+PZ=2H^$Z{I?S@UCTkO2SfDl^;7A1R_!adDw&jJ$O zlP^|5qx{CH+xiK#7&5OvH|%PvgQP~&b|x~&xmOEn8R2?=Ov>ubb<(>WDx(n^qo87O z;Jx#f9ply4l+NQ57F+X@1N;{lK9|Ef0vrldV)@BfYb9zHib`jcId9b|*tuUxXlXkb ziZHL=lW4NW@ZlY@P?!tT>7_QK2zh@&BUY19y!E4}b-2$cU@f7S!%jM`t#b=?n(J)yH=&!W;^07XXqkY{3-mV}T!UcfK@GLFrnXN?z0^-bu#^A05i5&&Mz{TqBl-T&(3t_FrO2%iFt``MwA7xQfFoJg@e|704t(Zo-4N<=q99in0G zP;@!fHqhct-lS?x6JkQAic3p!5F^2Uv)}OLveB%|u3Gj3#rbo#g|-Upcj84Ova2SK zeBWQX5A?tv-y>wWNle-nCqRF4n^k{k8DFKiakW5mTjGuX^8M+YEx%JSf^oRTH1av2 zQwK-Jiz)x)Cu40ym=@bfj{5K34rmv2VQFFHNcMdzNXicUg8AgAuM19L8seab^Qk_v z5^ys5l*+l|?zzo%(fDLg1_=e3i83&=(Ettka7sPO`h?-8)}6 zCn$%K7mxN-KqM${VopTfq!RKDFDkg&-G;RI5`X>Ubl@V0aKDRSdI{8gs|6wG(n!aU z|5V7MdCSj^8AF7mjMGmtl1MINhexPhqI$ML$tfa%3Pi9PbYF&!f-bf#-rxu8So zU6gY+?g+UZ0w49EbfZk8J8(^fmlAcS1K#DYIS=;9F*sg~vdg+uVK8KL6!J{w?F!sW5|xpVSCfkJY;&zc1l?HynV!#18iW$CW*|b2dWf14 zC{)22Xin1uuM?+X@PsF7ljix7s{<<1_kMUUyst|I*7O*qr5wsRQ<{yNfl)5jDu#M( zx=P|g+cDcMN!&y$Tk2dawFiQ=NfJ-a@_Jc|N@)1K3M=VDnK(?{w3FP0){AIgZ%Ve< zI)Z6DmXI9RZc-xU3L|BF_+y7-`9ZUVE_>qv6DR$nXu6Zz^GOpm#viZAW4q7_a+9Z~ z9x^S97Pp9KCu|bJ?K~JHZ2R#AIPn`8sIq5HsajB{)k|4ip;{xKcUOy8NU`w>9xv~A z-`&^MD&5G?!D8llVmQ2AsT-g*s2kWNlX4Ix@ zJx8F5sV3gF|s>!O9J9%;EwXTqwYBz_~c@_4iIY9(dI!W}|R~t6!=`*Vw z&Yb+dNd?UpG^jz_mezDE@n~khYX5{PKAWZ6iLI~sAMS$RTSUG%O%{2hYyae^MU>2> zN-~6x;iKMD+DP^%*h@<^Y~J5!X5rls@{lkEvb%k0F!eU&&lJum_k-@vb;_uxCU}V4 z`lI@c(;IeX3`DP|POraqvxyW+@Fh^knmU`BqR5Ce%0GM&&J-wI5$3?0hszMv$6>=9 zzf|@@(l;-b`}IX(&hXu;UE;nwA6)ZN?tyu|pO!#J@OPEue+bwLZ$J9@Y9v)I3UbIH z&TuN+-Mh+enD%tzpp>XwAWbQ+h$X?$(tU0x-caBSHZy{e(VB>$u1lWRX^uhip7woT zCRLJ`pM;rzZzeeH)eoeP{5W9{Zk8A5Sjlh1F)gMU^lc40#r^Kt0PR+zKJ#L%v`WIa zkM}dq8}_$Ih-+;Za0cpF2->ZIUi&5kSvQXT54G1X-Y9)sUlJ^2FGZXv@PM$c60d=X7+g2 z$4^}wl=jUsWz7KfJ#~&@^U-ct@7bsu*CjAZ9gc6LI8T-~Ibw4nP zBGU2TBiP^$|c;xI7mE zaTGncv@sDlu|s?+035wBiH|tq?D9RR1(c6Det}qcQ_NrBP{v8pBtzM7S!FudEi9Wu zamzeCsjE{b6!-mZ)BHHq9}%^M!+^dl$TwiqfJEhs+A`1^*|cW;LIbYmAiyIn-m;0A z8WPZP>`pt@pyeZ70e6(LtJ$Rh;Y}G)Vo#=QCNGZl^-RV%f1(iy-n1m8*(7BF2`V*H z^1M7OjTR$Kkyp8p;i?tga#iPZ)vWpvXfho8VQ8waf{tG8P`S;v@YF`D##r0=u{|rBWJN*MV{==zo?9l(b|EB`~-&WvXJW0Q)y8yr7pO4aS zVt#c#@Lz)5N+$njko(tnkSxr^{a=&r-w*W9vxqAk|97~pf{lYIaceC_PK}^nnn0Ds zMKj-~#kqiu?8sD7YLo8Bnea9ld;X8Ww6}iGi`BFUYyrLpL{aes5f^FMqY^M~LLZN9 zZxHA;dN>hTK(&uD3uYpaC!tJCP0LKpupCYgn{3ZP5&s53q#JF`U)o58wLk1G^>10i@JybNzsq_!Ooh|?MCAYxQcz< zgRS+mFB>IgL#Vblme#E%r*&0cx1>0?T)w4eEp_6cz8RKG>j8McBd|RXX^90ijM0J_ zf^;Ddrw#QchkLyb@*A8dU8HG5eXNeFjTrGAD)|RBGYOXvm=80R`>xZssw9+|e``%t z<6=s$EW7}>>eA}mV$%?MW zEcioj)XJRS^7q>oy0@=0-;*z^Qd)>|ciN8Od$oTFKcHHl=#qLpgOmQUdziPen7L@02%AQJtzd0A>8i{rFC@6h# zU=aJ!_u+EG0*_NOLh{~mW&67g+{D!U+(H}c1rhT-pov;6x6unqDoRSKgm?qD0@f5T*G(2ls*kFCPN~Dn#_?;MCN^UYqQqA`Uqr!K|>G zi4P@vz7jS!sSS1RXDL|@Dp1DH@Mij*^J{;c7@6o?sP7bd7sX9V9QaKdUyKK5Ey%fnZg5g-bo&(E;QgzqrpM;C38W0~QI_bOWz(H4;;o z=WsiPMkHOf3$az+uRAN&XXcAC^4{*QB;9X26wqQwxgy*8^ZIoTPDX6MO--|U{7%w+ zLewYu5ll7lgNw$PbzT;&gy#PBAM;i0XC+UTRUN>g`1j3nB)%T$RKB1RfG%Y|M@Z8= zb~*E!@A2$o{lX3JvP8P!1vrQ!jGl71F?dQ+(Ika^*ak~QjC?f_N1q^#W2e#)R_%B+ zYs%u;!tKIN3E?w6MT^5Duyw;x0e(jVED1ftk{Mh?UM+7sKXEjO%HXqC9I+IJ;o;?& zG1OaHT~_kRGbJq!**6S20{p!D;9e4X+q^GzZL^`T)@FMIHlHbTpxO2qZIO9DTw!Mp z5j2p#4g=aAy7E{=pxL%LM-AbArQxI4bi1#mi{3n&e8tv5yNl1|8MtS6A}-ftW>Rs+ zu}Dj2Pien@ix#Ob0e{BX5Asz~b<}fV-t~b5iG(a@XG-^;isLoSI(eA0$||GTHl{Sk zhDjH(QC9*z5ntAuJA9{TwjHS_Q+U0^y<+buG<#Wj(N>sDex0}^4%OIw#kLLZ;*U9I z$oY7OXVXD~nD&QCitIu3^Mec3{9kwsx-`<4Kd;KyVLi>-LTH?$*)~>b$LAh$+R=(m z!^8R~%(k_BsK}OwdPDZDyBGd3+Cnx%3H_DT`Sd|&@*KW5(*>GAu^UK_4Je&q@cLsV z97Mz{JHkuYZ^M&EjQ!bLR*M@em&hl`dbq68*{c$DOL3)9_j71;r`QsF9bvfHcl6AY zzwWak(%yrXOlV@h>3%~oB{5}e0Pm73cJh87od(3lj41C^$v1#R#6FZ zt+sEa-7LSYT0i~Gb9QKSs;{+v=e`vl>5gK$Xms zTDE~lFCHjX?O7OigpFBje%-!&(8=KvFiE5~Hi^|xVp&Ai3t(0A1DY!bDA~H8hiZ9| zu-O(8XDz?g(BLUejz-9M0IXP_-f{V4e9ES(d!|i;oFgZUv8Pk(iu@!@ntY+uH1uig z#+(fXIS^03ZfVhs7cMUIAxz9~tMt3EeoGtamkJRk7Jk@9e1r6LajMWMH(yP%$@pa2 zBQ>>#u{-W%=daTZl&9i@i5@LFy3=MdU$3gqTx>jj+ptFe%;T`U5@jOS@MWG%>}zcU z+=I1Wy3OLpK=;IP9ni`SlJ6bkW$pRB+~m^JxvKp5<9#2oEdK!RNnWO2Uabg-qUtmQ zpzvlvpV)fFg$>iOe=pU7J+!dNlr;|$_xYlBO7Y zMqATsix8J8o*@$!uHj&6?;9>{D```DU-J(opOS$4+j1t9MfDhp;965KkfoyJ{mMY1 zkf(HN*-jPqAk3NUn9J+R?L}~ysk58HR={CdF)KZz)f-8CNjnGD=%)|WRy!xOC1aXJ zWUW4=d=CQ%#N0u;f%i)WNU=M%=Ntp~9Um1Q@t$5de@JVqj8czs&*$3Tj81bsvI|)f z(m4XsWS{FDrmfc$DJ++FcB%x$ch2|m9*VMu!6ir5b{gb+ZwB|}-RU*fl_Toh z%PrW0m^9UZ1js)cq4u$Fc~7iteWDBvs@7rH2b1nutCOvD%=d9p`27Hq>?Y9|Ekb(N zjk_pRzU@7J8Ns}`Ke0{zU|Xv(10Z0=N4eW|=^&leFeVc9juq6bD}$0;y);U6a0)jN zz|mlD^tdz=BJ=mnjF^;-!5iEVB!!AtEP$Aawbn9!JYVip3i+`9iNoR6C@*J5V35~! zeFb}aTAK`(IXjvpJJ1F`8iSkXaZqd!J`qEWRDW#m;J1#Lo$AQ38SRA2S`=V|k>BaV24f9h-f5 zPiMKV?epiKDk6A=2zL6k$#r3p2l?*nyRWU@s1~43Y&bO?zBN^B(D((e6H&ap5!;K%eW&R zc%SqxLhSB-RrT{l73qDT$_$^(?McMwus44AMVlG&URvITEJMnPjwN+`{%dp~#YyGD zw^~rGQcKW974dy|H2FqYok9BW5D;VOFdG0NS|&5rISrHAgSt6Dd<34tJzQrCj^AIv zo7r<`1K^6Mvq5+zmp%T_PKjNVXYS3ByK>~Gk>MB8BE8c_A;sLJBuZW*mT4y_p665> z0hMSIgN3?i#xbOfVJZbPvIFTMl`ng^{ncnJp4>Drn&s&Dj*{%IN}S!3MyvJXIUvz; z5l_=W7R6n|phdW2A3_Ozr+96jPgYUoJZb6urNw1t|-JHm|3^<8(@qH06Y9gGb0 z{{wCl#_HFkplHDScUd z0D%F`4DnEMbE)A6-|xU$#7x({1x&e!ob75VXEd!wl-UGgEtgD4wY|>+#uJ*d!8gL( zuRT)husxb(MyiB$uLU{emnxu^R%rMh@Rl#Rf7Q3_Hq&~?e_O>oz55y;7cqPB-MokI z4>O-j?iR_-h<1Ehxbgml6szR(*=~=9i-qGn)tlCAOxwfMvSy;rU`qKtPQVA1z|Zfv z^9_yg6IU(F^1CeL#7gc2n5oD~v9KPV>Ij5ZZrgwANs9zQ*_67hce>I4rvS}$_|r+O zuU9RbO!XRNc^nN|Dm;3P>Ag)FYeQiA1xw!YuKz7%Sk>@*m<5-aZQAColpv|v>J=6y zItd?&$x+><^a#JE$TizYp9SrMvR^BUp%b4AvFxWpAlClntAxzq?P%`BumE zgL5^}1FiOgqCC$y*!NMc&{k_+`K%+gfVPU1H+x?~Dx~p=T>1_x2edMYX8EG+jX`b~ zwOTd3mOAOdnPEA-!RH(g8)mBz(rwdz8r+mLe%u9>8gZ00^j0&&4@9GJ?i*@HO!|~V z4)H>O5$OW#iCm>{d>2oS{@rJ_$6vSdr|3dAvpV26t1?&Bv?qGXHd-fhoQKt^!`DdE z0;wm}pPz7E+IN<$$t>QLz1HZ`LF;aXKSD={D48FBeemf+F-zr^M(|qsrEBwXrYdt$ z-Rf3AOY)<0wioY}+n8+UyS1lQI*HkQtbM9S;zv?JGsevmc@=w~0^LsG1vLFWt)Jsi zsh-RlN#^U1igpI0Uwq9z`m}nhXfeq9%Rr?WoVv?;5GShij4%UdLuBGoDN8mJ_P7N> z_rvHl!vVqJbQm$Eq8!Y>`4}Vz36qGH73_Sata7~s(X%c%XPxL6-Pwy8 zMrqzSe#sMNn97bZt8vdlGLrKK0p6vVBOB$C97mwh2C^5v+?e00yu0#D|B=H5F^Ti@ z8)@6=BAAFQx7P&^;aH^Skh#&oMLCWy)Mxe=R7z7mOL|In-$uXY^oUmWD=E(7G-2Ma z!WtOY;)}K_DOw-&^-yIvjnwRxwX2WN(9{cw(CcC86Qo(4@}aKI-01#39dMWSLO+x` zRF}6qp5gQjIR4fC@cZZIK0(0}??S{%9ikq|tLM5|RNSlNG@qPl&z3B;qNBPcLkNJ z(gIzxXx6v-Ko1!WG!$!i`pxs0O}DTJAW!*}%5|L?Hu1=+S<`%D{MT#Je3{gQZ{t#I zyxbCKdMPE|@kxzEirRuX^dU0f7v2`exx(A?aNjdD-o{-`E&WgNHb`$)F!Eo3tKUtW zXui$+C%O8ko=u=8#K{&65Rs9Vk>TXzmqHIA^pO5_oecJHzzkgxbY*CV)QZ z_>VF^Za#F6?_XsCf0yxa|IHsC@4wLF75rPi{5)4jCjW5_ejc8`%DA|=IRBa(TE>lj z)_&VM!Au~wVA#)ob`6LJx!D@2XQ-9STLarT?P~*#zeFbDOxh__=v- 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