Move OPML I/O functions into a separate OPMLFile class

This commit is contained in:
Maurice Parker 2019-09-12 18:05:29 -05:00
parent 979fcbc013
commit 83476baa5b
4 changed files with 167 additions and 132 deletions

View File

@ -167,7 +167,9 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
static let saveQueue = CoalescingQueue(name: "Account Save Queue", interval: 1.0)
private var unreadCounts = [String: Int]() // [feedID: Int]
private let opmlFilePath: String
private lazy var opmlFile: OPMLFile = {
OPMLFile(filename: (dataFolder as NSString).appendingPathComponent("Subscriptions.opml"), account: self)
}()
private var _flattenedFeeds = Set<Feed>()
private var flattenedFeedsNeedUpdate = true
@ -251,8 +253,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
self.type = type
self.dataFolder = dataFolder
self.opmlFilePath = (dataFolder as NSString).appendingPathComponent("Subscriptions.opml")
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3")
self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID)
@ -396,6 +396,44 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
}
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
var feedsToAdd = Set<Feed>()
items.forEach { (item) in
if let feedSpecifier = item.feedSpecifier {
let feed = newFeed(with: feedSpecifier)
feedsToAdd.insert(feed)
return
}
guard let folderName = item.titleFromAttributes else {
// Folder doesnt have a name, so it wont be created, and its items will go one level up.
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: parentFolder)
}
return
}
if let folder = ensureFolder(with: folderName) {
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: folder)
}
}
}
if let parentFolder = parentFolder {
for feed in feedsToAdd {
parentFolder.addFeed(feed)
}
} else {
for feed in feedsToAdd {
addFeed(feed)
}
}
}
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag)
}
@ -501,19 +539,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
structureDidChange()
}
func loadOPML(_ opmlDocument: RSOPMLDocument) {
guard let children = opmlDocument.children else {
return
}
loadOPMLItems(children, parentFolder: nil)
structureDidChange()
DispatchQueue.main.async {
self.refreshAll() { result in }
}
}
public func updateUnreadCounts(for feeds: Set<Feed>) {
if feeds.isEmpty {
return
@ -590,32 +615,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
return database.fetchArticleIDsForStatusesWithoutArticles()
}
public func opmlDocument() -> String {
let escapedTitle = nameForDisplay.rs_stringByEscapingSpecialXMLCharacters()
let openingText =
"""
<?xml version="1.0" encoding="UTF-8"?>
<!-- OPML generated by NetNewsWire -->
<opml version="1.1">
<head>
<title>\(escapedTitle)</title>
</head>
<body>
"""
let middleText = OPMLString(indentLevel: 0)
let closingText =
"""
</body>
</opml>
"""
let opml = openingText + middleText + closingText
return opml
}
public func unreadCount(for feed: Feed) -> Int {
return unreadCounts[feed.feedID] ?? 0
}
@ -773,7 +772,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
@objc func saveToDiskIfNeeded() {
if dirty && !isDeleted {
saveToDisk()
dirty = false
opmlFile.save()
}
}
@ -967,7 +967,7 @@ private extension Account {
func pullObjectsFromDisk() {
loadAccountMetadata()
loadFeedMetadata()
loadOPMLFile(path: opmlFilePath)
opmlFile.load()
}
func loadAccountMetadata() {
@ -991,52 +991,6 @@ private extension Account {
feedMetadata.values.forEach { $0.delegate = self }
}
func loadOPMLFile(path: String) {
let opmlFileURL = URL(fileURLWithPath: path)
var fileData: Data?
do {
fileData = try Data(contentsOf: opmlFileURL)
} catch {
// Commented out because its not an error on first run.
// TODO: make it so we know if its first run or not.
//NSApplication.shared.presentError(error)
return
}
guard let opmlData = fileData else {
return
}
let parserData = ParserData(url: opmlFileURL.absoluteString, data: opmlData)
var opmlDocument: RSOPMLDocument?
do {
opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
} catch {
os_log(.error, log: log, "OPML Import failed: %@.", error.localizedDescription)
return
}
guard let parsedOPML = opmlDocument, let children = parsedOPML.children else {
return
}
BatchUpdate.shared.perform {
loadOPMLItems(children, parentFolder: nil)
}
}
func saveToDisk() {
dirty = false
let opmlDocumentString = opmlDocument()
do {
let url = URL(fileURLWithPath: opmlFilePath)
try opmlDocumentString.write(to: url, atomically: true, encoding: .utf8)
}
catch let error as NSError {
os_log(.error, log: log, "Save to disk failed: %@.", error.localizedDescription)
}
}
func queueSaveFeedMetadataIfNeeded() {
Account.saveQueue.add(self, #selector(saveFeedMetadataIfNeeded))
}
@ -1121,44 +1075,6 @@ private extension Account {
feedDictionaryNeedsUpdate = false
}
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
var feedsToAdd = Set<Feed>()
items.forEach { (item) in
if let feedSpecifier = item.feedSpecifier {
let feed = newFeed(with: feedSpecifier)
feedsToAdd.insert(feed)
return
}
guard let folderName = item.titleFromAttributes else {
// Folder doesnt have a name, so it wont be created, and its items will go one level up.
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: parentFolder)
}
return
}
if let folder = ensureFolder(with: folderName) {
if let itemChildren = item.children {
loadOPMLItems(itemChildren, parentFolder: folder)
}
}
}
if let parentFolder = parentFolder {
for feed in feedsToAdd {
parentFolder.addFeed(feed)
}
} else {
for feed in feedsToAdd {
addFeed(feed)
}
}
}
func updateUnreadCount() {
if fetchingAllUnreadCounts {
return

View File

@ -29,6 +29,7 @@
5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71D22835E9800D9D53D /* FeedSpecifier.swift */; };
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */; };
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; };
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; };
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; };
51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; };
51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; };
@ -140,6 +141,7 @@
5165D71D22835E9800D9D53D /* FeedSpecifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedSpecifier.swift; sourceTree = "<group>"; };
5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLFeedFinder.swift; sourceTree = "<group>"; };
5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = "<group>"; };
5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = "<group>"; };
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = "<group>"; };
@ -333,6 +335,7 @@
841974241F6DDCE4006346C4 /* AccountDelegate.swift */,
51E3EB40229AF61B00645299 /* AccountError.swift */,
846E77531F6F00E300A165E2 /* AccountManager.swift */,
5170743B232AEDB500A461A3 /* OPMLFile.swift */,
84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */,
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */,
84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */,
@ -591,6 +594,7 @@
5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */,
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,

View File

@ -77,11 +77,14 @@ final class LocalAccountDelegate: AccountDelegate {
return
}
// We use the same mechanism to load local accounts as we do to load the subscription
// OPML all accounts.
BatchUpdate.shared.perform {
account.loadOPML(loadDocument)
guard let children = loadDocument.children else {
return
}
BatchUpdate.shared.perform {
account.loadOPMLItems(children, parentFolder: nil)
}
completion(.success(()))
}

View File

@ -0,0 +1,112 @@
//
// OPMLFile.swift
// Account
//
// Created by Maurice Parker on 9/12/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os.log
import RSCore
import RSParser
final class OPMLFile: NSObject, NSFilePresenter {
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "account")
private let filename: String
private let account: Account
private let operationQueue: OperationQueue
var presentedItemURL: URL? {
return URL(string: filename)
}
var presentedItemOperationQueue: OperationQueue {
return operationQueue
}
init(filename: String, account: Account) {
self.filename = filename
self.account = account
operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
}
func load() {
let opmlFileURL = URL(fileURLWithPath: filename)
var fileData: Data?
do {
fileData = try Data(contentsOf: opmlFileURL)
} catch {
// Commented out because its not an error on first run.
// TODO: make it so we know if its first run or not.
//NSApplication.shared.presentError(error)
return
}
guard let opmlData = fileData else {
return
}
let parserData = ParserData(url: opmlFileURL.absoluteString, data: opmlData)
var opmlDocument: RSOPMLDocument?
do {
opmlDocument = try RSOPMLParser.parseOPML(with: parserData)
} catch {
os_log(.error, log: log, "OPML Import failed: %@.", error.localizedDescription)
return
}
guard let parsedOPML = opmlDocument, let children = parsedOPML.children else {
return
}
BatchUpdate.shared.perform {
account.loadOPMLItems(children, parentFolder: nil)
}
}
func save() {
let opmlDocumentString = opmlDocument()
do {
let url = URL(fileURLWithPath: filename)
try opmlDocumentString.write(to: url, atomically: true, encoding: .utf8)
} catch let error as NSError {
os_log(.error, log: log, "Save to disk failed: %@.", error.localizedDescription)
}
}
}
private extension OPMLFile {
func opmlDocument() -> String {
let escapedTitle = account.nameForDisplay.rs_stringByEscapingSpecialXMLCharacters()
let openingText =
"""
<?xml version="1.0" encoding="UTF-8"?>
<!-- OPML generated by NetNewsWire -->
<opml version="1.1">
<head>
<title>\(escapedTitle)</title>
</head>
<body>
"""
let middleText = account.OPMLString(indentLevel: 0)
let closingText =
"""
</body>
</opml>
"""
let opml = openingText + middleText + closingText
return opml
}
}