-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathFeed.swift
430 lines (349 loc) · 14 KB
/
Feed.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//
// Feed.swift
// RSSReader
//
// Created by Mitchell Cooper on 9/18/14.
// Copyright (c) 2014 Mitchell Cooper. All rights reserved.
//
import UIKit
class Feed: NSObject, CustomStringConvertible, ArticleCollection, XMLParserDelegate {
// failable if URL is invalid
init?(group: FeedGroup, urlString string: String!) {
var string = string
// if the string is nil, it just won't work
if string == nil {
urlString = ""
self.group = rss.defaultGroup
super.init()
return nil
}
// if feed:// replace with http://
if let r = string.rangeOfString("feed://", options: nil, range: nil, locale: nil) {
string.replaceRange(r, with: "http://")
}
// feed:http://
if (string?.hasPrefix("feed:"))! {
string.removeSubrange((string.startIndex ..< advance(string.startIndex, 5)))
}
self.group = group
urlString = string!
super.init()
// check validity
if url.scheme == nil || !contains(["http", "https"], url.scheme!) {
return nil
}
}
// failable if mandatory data is missing
convenience init?(group: FeedGroup, storage: NSDictionary) {
self.init(group: group, urlString: storage["urlString"] as? String)
// optional values
channelTitle = storage["channelTitle"] as? String
userSetTitle = storage["userSetTitle"] as? String
iconUrlString = storage["iconUrlString"] as? String
logoUrlString = storage["logoUrlString"] as? String
iconFileName = storage["iconFileName"] as? String
logoFileName = storage["logoFileName"] as? String
// add each article
if let stored = storage["articles"] as? [NSDictionary] {
for info in stored {
let article = Article(feed: self, storage: info)
// check if too old
if article.expired && !article.saved {
continue
}
articles.append(article)
}
}
}
// MARK:- Notifications
struct Notifications {
static let Fetched = "FeedWasFetchedNotification"
static let Error = "FeedErrorOccurredNotification"
}
// MARK:- Properties
// URL
//
// urlString is persistent. URLs cannot be stored in Core Data/plist.
// Therefore, url is a computed URL value of the urlString.
//
var urlString: String
var url: URL { return URL(string: urlString)! }
var identifier: String { return urlString }
// Group
//
// The group is set when loaded from storage
// or when the feed is first added to the group
//
// it is unowned because the group will hold a strong
// reference to the feed; therefore, the feed should
// not hold a strong reference to the group
//
unowned var group: FeedGroup
// Articles
//
// articles is the list of articles in no
// particular order
//
// articlesById is a computed property which makes
// it easier to determine which articles exist already,
// based on their URL string.
//
var articles = [Article]()
// feeds for article
var feeds: [Feed] { return [self] }
var articlesById: [String: Article] {
var byId = [String: Article]()
for article in articles {
byId[article.identifier] = article
}
return byId
}
var unread: [Article] {
return articles.filter { !$0.read }
}
var saved: [Article] {
return articles.filter { $0.saved }
}
// Titles
//
// channelTitle represents the title assigned by the feed itself.
// userSetTitle represents the title set by the user, or nickname.
// Both properties are persistent.
//
// title chooses the best possible title available, the first that exists of:
// user set title,
// channel-set title,
// feed URL string
//
// the short title is a property of the article collection,
// used for display in the search bar.
//
var channelTitle: String? // actual title from the feed
var userSetTitle: String? // nickname assigned by user
// best option
var title: String {
return userSetTitle ?? channelTitle ?? url.absoluteString
}
// used in search bar
var shortTitle: String {
let short = (userSetTitle ?? channelTitle)?.components(separatedBy: " ")[0]
return short ?? url.absoluteString
}
// Images
//
// iconUrlString and logoUrlString are persistent and set by the feed itself.
//
// logoData and iconData are also persistent and are the stored data which
// may have been downloaded after the feed was fetched a previous time.
//
// logo and icon are lazy variables which will be computed after the feed is
// retrieved from Core Data/plist, but they will also be re-set again later if the
// images are downloaded and have been modified.
//
var iconUrlString: String? // URL of icon representing of the feed
var logoUrlString: String? // URL of logo representing of the feed
var iconFileName: String?
var logoFileName: String?
var logoData: Data? // data representing the logo
var iconData: Data? // data representing the icon
lazy var logo: UIImage? = {
if let data = self.logoData {
return UIImage(data: data)
}
if var file = self.logoFileName {
file = rss.manager.documents.appendingPathComponent(file)
if let data = try? Data(contentsOf: URL(fileURLWithPath: file)) {
self.logoData = data
return UIImage(data: data)
}
}
return nil
}()
lazy var icon: UIImage? = {
if let data = self.iconData {
return UIImage(data: data)
}
if var file = self.iconFileName {
file = rss.manager.documents.appendingPathComponent(file)
if let data = try? Data(contentsOf: URL(fileURLWithPath: file)) {
self.iconData = data
return UIImage(data: data)
}
}
return nil
}()
// MARK: Non-persistent properties
var shouldFetchIcon = false // whether it's necessary to fetch icon
var shouldFetchLogo = false // whether it's necessary to fetch logo
var loading = false // is it being fetched now?
weak var currentGroup: FeedGroup? // current feed group in user interface
// printable description
override var description: String {
return "Feed \(title)"
}
// MARK:- Methods
// add an article to the feed.
func addArticle(_ article: Article) {
// this article was deleted
if contains(rss.manager.deletedArticleIDs, article.identifier) {
rss.log("Ignoring deleted article \(article.title)")
return
}
// already exists; update the old one.
if let existing = articlesById[article.identifier] {
existing.updateWithArticle(article)
return
}
articles.append(article)
}
// fetch the feed data.
func fetchThen(_ then: ((Void) -> Void)?) {
rss.log("Fetching \(self.title)")
loading = true
rss.activityLevel += 1
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 3)
NSURLConnection.sendAsynchronousRequest(request, queue: rss.feedQueue) {
res, data, error in
rss.activityLevel--
self.loading = false
// error.
if error != nil {
rss.log("There was an error in \(self.title): \(error)")
mainQueue {
// show error on current view controller, if any
// remember that we've shown an error. it will be
// unset when the user taps OK button.
var canShowError = true
if let vc = rss.currentFeedVC {
if canShowError {
let alert = PSTAlertController(title: "Feed error", message: "An error occurred for \"\(self.title)\": \(error.localizedDescription)", preferredStyle: .Alert)
alert.addAction(PSTAlertAction(title: "OK") { _ in
canShowError = true
return
})
alert.showWithSender(nil, controller: vc, animated: true, completion: nil)
canShowError = false
}
}
rss.center.post(name: Notification.Name(rawValue: Notifications.Error), object: self)
then?()
}
// retry after five seconds, but don't retain self.
after(5) { [weak self] in
self?.fetchThen(then)
return
}
return
}
// initiate XML parser in this same queue.
let parser = XMLParser(feed: self, data: data)
parser.parse()
// download logo/icon.
self.downloadImages()
// in the main queue, update UI and call callback.
mainQueue {
self.reloadCells()
then?()
rss.center.post(name: Notification.Name(rawValue: Notifications.Fetched), object: self)
}
return
}
}
// fetch an image at the specific URL iff doIt is true, calling handler
// with the NSData and UIImage if/when completed successfully
func fetchImage(_ urlString: String!, _ doIt: Bool, handler: @escaping (Data, UIImage) -> Void) {
// no image URL specified.
if urlString == nil || !doIt { return }
// send the request from the feed queue.
let request = URLRequest(url: URL(string: urlString)!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 5)
rss.activityLevel += 1
NSURLConnection.sendAsynchronousRequest(request, queue: rss.feedQueue) {
res, data, error in
rss.activityLevel--
// error.
if error != nil {
rss.log("There was an error loading the image: \(error)")
return
}
// create UIImage, remove white background, if any, then
// convert back to binary data for storage in Core Data/documents.
// working with UIImage is threadsafe now.
if var image = UIImage(data: data) {
image = image.withoutWhiteBackground
handler(image.pngRepresentation!, image)
}
// reload the table in the main queue, if there is one visible.
mainQueue {
self.reloadCells()
return
}
}
}
// download all images associated with the feed.
func downloadImages() {
// feed logo
fetchImage(logoUrlString, shouldFetchLogo) {
data, image in
self.logoData = data
self.logo = image
let file = self.identifier.fileNameSafe + "-logo.png"
let path = rss.manager.documents.appendingPathComponent(file)
rss.log("Writing logo to \(path)")
try? image.pngRepresentation!.write(to: URL(fileURLWithPath: path), options: [.atomic])
self.logoFileName = file
}
// feed icon
fetchImage(iconUrlString, shouldFetchIcon) {
data, image in
self.iconData = data
self.icon = image
let file = self.identifier.fileNameSafe + "-icon.png"
let path = rss.manager.documents.appendingPathComponent(file)
rss.log("Writing icon to \(path)")
try? image.pngRepresentation!.write(to: URL(fileURLWithPath: path), options: [.atomic])
self.iconFileName = file
}
// article thumbnails
for article in articles {
article.fetchThumb()
}
}
// reload the visible cells associated with the feed, if any.
// this only applies to the topmost feed list, but that's okay because
// any others will be updated when receiving viewWillAppear:.
func reloadCells() {
if let feedVC = rss.currentFeedVC {
if let myRow = find(feedVC.group.feeds, self) {
let path = IndexPath(row: myRow, section: feedVC.secFeedList)
feedVC.tableView.reloadRows(at: [path], with: .automatic)
}
}
}
// convenience method for fetching with no callback.
func fetch() {
fetchThen(nil)
}
// MARK: Persistence
func forStorage() -> NSDictionary {
// these values always present
var forStorage: [String: AnyObject] = [
"articles": articles.map { $0.forStorage() },
"identifier": identifier,
"urlString": urlString
]
// add present values from these
let maybe = [
"channelTitle": channelTitle,
"userSetTitle": userSetTitle,
"iconUrlString": iconUrlString,
"logoUrlString": logoUrlString,
"iconFileName": iconFileName,
"logoFileName": logoFileName
]
for (key, val) in maybe {
if val == nil { continue }
forStorage[key] = val! as AnyObject
}
return forStorage as NSDictionary
}
}