2019-12-20 19:09:20 -08:00
|
|
|
//
|
2021-08-11 21:57:49 -07:00
|
|
|
// WidgetCache.swift
|
2019-12-20 19:09:20 -08:00
|
|
|
// Widget Extension
|
|
|
|
//
|
2021-08-11 21:57:49 -07:00
|
|
|
// Created by David Sinclair on 2021-08-07.
|
|
|
|
// Copyright © 2021 NewsBlur. All rights reserved.
|
2019-12-20 19:09:20 -08:00
|
|
|
//
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
import WidgetKit
|
2019-12-20 19:09:20 -08:00
|
|
|
import UIKit
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
enum WidgetCacheError: String, Error {
|
2019-12-20 19:09:20 -08:00
|
|
|
case notLoggedIn
|
|
|
|
case loading
|
|
|
|
case noFeeds
|
|
|
|
case noStories
|
|
|
|
}
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
class WidgetCache {
|
2019-12-20 19:09:20 -08:00
|
|
|
/// The base URL of the NewsBlur server.
|
|
|
|
var host: String?
|
|
|
|
|
|
|
|
/// The secret token for authentication.
|
|
|
|
var token: String?
|
|
|
|
|
2019-12-23 21:19:45 -08:00
|
|
|
/// An array of feeds to load.
|
|
|
|
var feeds = [Feed]()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
/// Loaded stories.
|
|
|
|
var stories = [Story]()
|
|
|
|
|
|
|
|
/// An error to display instead of the stories, or `nil` if the stories should be displayed.
|
2021-08-11 21:57:49 -07:00
|
|
|
var error: WidgetCacheError?
|
2019-12-20 19:09:20 -08:00
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
typealias CacheCompletion = () -> Void
|
|
|
|
|
|
|
|
private var cacheCompletion: CacheCompletion?
|
|
|
|
|
|
|
|
private typealias AnyDictionary = [String : Any]
|
|
|
|
|
|
|
|
private typealias ImageDictionary = [String : UIImage]
|
|
|
|
|
|
|
|
private var feedImageCache = ImageDictionary()
|
|
|
|
private var storyImageCache = ImageDictionary()
|
|
|
|
|
|
|
|
private typealias LoaderDictionary = [String : Loader]
|
|
|
|
|
|
|
|
private var loaders = LoaderDictionary()
|
|
|
|
|
|
|
|
// Paragraph style for title and content labels.
|
|
|
|
// lazy var paragraphStyle: NSParagraphStyle = {
|
|
|
|
// let paragraph = NSMutableParagraphStyle()
|
|
|
|
//
|
|
|
|
// paragraph.lineBreakMode = .byTruncatingTail
|
|
|
|
// paragraph.alignment = .left
|
|
|
|
// paragraph.lineHeightMultiple = 0.95
|
|
|
|
//
|
|
|
|
// return paragraph
|
|
|
|
// }()
|
2019-12-23 21:19:45 -08:00
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
struct Constant {
|
|
|
|
static let group = "group.com.newsblur.NewsBlur-Group"
|
|
|
|
static let token = "share:token"
|
|
|
|
static let host = "share:host"
|
2019-12-23 21:19:45 -08:00
|
|
|
static let feeds = "widget:feeds_array"
|
2019-12-20 19:09:20 -08:00
|
|
|
static let widgetFolder = "Widget"
|
|
|
|
static let storiesFilename = "Stories.json"
|
2020-01-23 15:20:28 -08:00
|
|
|
static let feedImagesFilename = "Feed Images"
|
|
|
|
static let storyImagesFilename = "Story Images"
|
2019-12-20 19:09:20 -08:00
|
|
|
static let imageExtension = "png"
|
|
|
|
static let limit = 5
|
|
|
|
static let defaultRowHeight: CGFloat = 110
|
|
|
|
static let storyImageSize: CGFloat = 64 * 3
|
2020-01-23 15:20:28 -08:00
|
|
|
static let storyImageLimit: CGFloat = 200
|
2020-01-23 20:27:29 -08:00
|
|
|
static let thumbnailHiddenConstant: CGFloat = -50
|
|
|
|
static let thumbnailShownConstant: CGFloat = 20
|
2019-12-20 19:09:20 -08:00
|
|
|
}
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
func load(completionHandler: @escaping CacheCompletion) {
|
2019-12-23 21:19:45 -08:00
|
|
|
let feedIds = feeds.map { $0.id }
|
|
|
|
let combinedFeeds = feedIds.joined(separator: "&f=")
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
guard let url = hostURL(with: "/reader/river_stories/?include_hidden=false&page=1&infrequent=false&order=newest&read_filter=unread&limit=\(Constant.limit)&f=\(combinedFeeds)") else {
|
2021-08-11 21:57:49 -07:00
|
|
|
error = .loading
|
|
|
|
completionHandler()
|
2019-12-20 19:09:20 -08:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
error = nil
|
2021-08-11 21:57:49 -07:00
|
|
|
cacheCompletion = completionHandler
|
2019-12-20 19:09:20 -08:00
|
|
|
loaders[Constant.storiesFilename] = Loader(url: url, completion: storyLoaderCompletion(result:))
|
|
|
|
}
|
|
|
|
|
|
|
|
func cleaned(_ string: String) -> String {
|
|
|
|
let clean = string.prefix(1000).replacingOccurrences(of: "\n", with: "")
|
2019-12-23 21:19:45 -08:00
|
|
|
.replacingOccurrences(of: "<[^>]+>|&[^;]+;", with: " ", options: .regularExpression, range: nil)
|
2019-12-20 19:09:20 -08:00
|
|
|
.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
|
|
|
return clean.isEmpty ? " " : clean
|
|
|
|
}
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
// func attributed(_ string: String, with font: UIFont, color: UIColor) -> NSAttributedString {
|
|
|
|
// let attributes: [NSAttributedString.Key : Any] = [.font : font, .foregroundColor: color, .paragraphStyle: paragraphStyle]
|
|
|
|
//
|
|
|
|
// return NSAttributedString(string: cleaned(string), attributes: attributes)
|
|
|
|
// }
|
2019-12-23 21:19:45 -08:00
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
func hostURL(with path: String) -> URL? {
|
|
|
|
guard let host = host else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if let token = token {
|
|
|
|
return URL(string: host + path + "&secret_token=\(token)")
|
|
|
|
} else {
|
|
|
|
return URL(string: host + path)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func storyLoaderCompletion(result: Result<Data, Error>) {
|
|
|
|
defer {
|
2021-08-11 21:57:49 -07:00
|
|
|
cacheCompletion = nil
|
2019-12-20 19:09:20 -08:00
|
|
|
loaders[Constant.storiesFilename] = nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if case .failure = result {
|
2021-08-11 21:57:49 -07:00
|
|
|
error = .loading
|
|
|
|
cacheCompletion?()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard case .success(let data) = result else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? AnyDictionary else {
|
2021-08-11 21:57:49 -07:00
|
|
|
error = .loading
|
|
|
|
cacheCompletion?()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let storyArray = dictionary["stories"] as? [AnyDictionary] else {
|
2021-08-11 21:57:49 -07:00
|
|
|
error = .loading
|
|
|
|
cacheCompletion?()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
stories.removeAll()
|
|
|
|
|
|
|
|
for storyDict in storyArray {
|
|
|
|
stories.append(Story(from: storyDict))
|
|
|
|
}
|
|
|
|
|
|
|
|
saveStories()
|
2020-01-23 15:20:28 -08:00
|
|
|
flushStoryImages()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
if stories.isEmpty, error == nil {
|
|
|
|
error = .noStories
|
|
|
|
}
|
|
|
|
|
2019-12-21 15:48:17 -08:00
|
|
|
// Keep a local copy, since the property will be cleared before the async closure is called.
|
2021-08-11 21:57:49 -07:00
|
|
|
let localCompletion = cacheCompletion
|
2019-12-21 15:48:17 -08:00
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
DispatchQueue.main.async {
|
2021-08-11 21:57:49 -07:00
|
|
|
// self.extensionContext?.widgetLargestAvailableDisplayMode = self.error == nil ? .expanded : .compact
|
|
|
|
//
|
|
|
|
// self.tableView.reloadData()
|
|
|
|
// self.tableView.setNeedsDisplay()
|
2019-12-20 19:09:20 -08:00
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
localCompletion?()
|
2019-12-20 19:09:20 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var groupContainerURL: URL? {
|
|
|
|
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constant.group)
|
|
|
|
}
|
|
|
|
|
|
|
|
var widgetFolderURL: URL? {
|
|
|
|
return groupContainerURL?.appendingPathComponent(Constant.widgetFolder)
|
|
|
|
}
|
|
|
|
|
|
|
|
var storiesURL: URL? {
|
|
|
|
return widgetFolderURL?.appendingPathComponent(Constant.storiesFilename)
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
var feedImagesURL: URL? {
|
|
|
|
return widgetFolderURL?.appendingPathComponent(Constant.feedImagesFilename)
|
|
|
|
}
|
|
|
|
|
|
|
|
var storyImagesURL: URL? {
|
|
|
|
return widgetFolderURL?.appendingPathComponent(Constant.storyImagesFilename)
|
|
|
|
}
|
|
|
|
|
|
|
|
func createWidgetFolder(url: URL? = nil) {
|
|
|
|
guard let url = url ?? widgetFolderURL else {
|
2019-12-20 19:09:20 -08:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
|
|
|
}
|
|
|
|
|
2021-08-11 21:57:49 -07:00
|
|
|
func stories(count: Int) -> [Story] {
|
|
|
|
return Array(stories.prefix(count))
|
|
|
|
}
|
|
|
|
|
|
|
|
func feed(for story: Story) -> Feed? {
|
|
|
|
return feeds.first(where: { $0.id == story.feed })
|
|
|
|
}
|
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
func loadCachedStories() {
|
2019-12-23 21:19:45 -08:00
|
|
|
feeds = []
|
2019-12-20 19:09:20 -08:00
|
|
|
stories = []
|
|
|
|
|
|
|
|
guard let defaults = UserDefaults.init(suiteName: Constant.group) else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
host = defaults.string(forKey: Constant.host)
|
|
|
|
token = defaults.string(forKey: Constant.token)
|
|
|
|
|
2019-12-23 21:19:45 -08:00
|
|
|
if let array = defaults.array(forKey: Constant.feeds) as? [Feed.Dictionary] {
|
|
|
|
feeds = array.map { Feed(from: $0) }
|
2019-12-20 19:09:20 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
guard let url = storiesURL else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
let json = try Data(contentsOf: url)
|
|
|
|
let decoder = JSONDecoder()
|
|
|
|
|
|
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
|
|
|
|
|
|
stories = try decoder.decode([Story].self, from: json)
|
|
|
|
} catch {
|
|
|
|
print("Error \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func saveStories() {
|
|
|
|
guard let url = storiesURL else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
createWidgetFolder()
|
|
|
|
|
|
|
|
let encoder = JSONEncoder()
|
|
|
|
|
|
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
|
|
encoder.outputFormatting = .prettyPrinted
|
|
|
|
|
|
|
|
do {
|
|
|
|
let json = try encoder.encode(stories)
|
|
|
|
|
|
|
|
try json.write(to: url)
|
|
|
|
} catch {
|
|
|
|
print("Error \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
func save(feedImage: UIImage, for identifier: String) {
|
|
|
|
feedImageCache[identifier] = feedImage
|
|
|
|
save(image: feedImage, to: feedImagesURL, for: identifier)
|
|
|
|
}
|
|
|
|
|
|
|
|
func save(storyImage: UIImage, for identifier: String) {
|
|
|
|
storyImageCache[identifier] = storyImage
|
|
|
|
save(image: storyImage, to: storyImagesURL, for: identifier)
|
|
|
|
}
|
|
|
|
|
|
|
|
func save(image: UIImage, to folderURL: URL?, for identifier: String) {
|
|
|
|
guard let folderURL = folderURL else {
|
|
|
|
return
|
2019-12-20 19:09:20 -08:00
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
createWidgetFolder(url: folderURL)
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
do {
|
|
|
|
let imageURL = folderURL.appendingPathComponent(identifier).appendingPathExtension(Constant.imageExtension)
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
try image.pngData()?.write(to: imageURL)
|
2019-12-20 19:09:20 -08:00
|
|
|
} catch {
|
|
|
|
print("Image error: \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
func flushStoryImages() {
|
|
|
|
guard let folderURL = storyImagesURL else {
|
2019-12-20 19:09:20 -08:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
2020-01-23 15:20:28 -08:00
|
|
|
let manager = FileManager.default
|
|
|
|
let contents = try manager.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: [], options: .skipsHiddenFiles)
|
2019-12-20 19:09:20 -08:00
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
for imageURL in contents {
|
|
|
|
let identifier = imageURL.deletingPathExtension().lastPathComponent
|
|
|
|
|
|
|
|
if stories.contains(where: { $0.id == identifier }) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
try manager.removeItem(at: imageURL)
|
|
|
|
storyImageCache[identifier] = nil
|
|
|
|
}
|
2019-12-20 19:09:20 -08:00
|
|
|
} catch {
|
2020-01-23 15:20:28 -08:00
|
|
|
print("Flush story images error: \(error)")
|
2019-12-20 19:09:20 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
typealias ImageCompletion = (UIImage?, String?) -> Void
|
|
|
|
|
|
|
|
func feedImage(for feed: String, completion: @escaping ImageCompletion) {
|
|
|
|
guard let url = hostURL(with: "/reader/favicons?feed_ids=\(feed)") else {
|
|
|
|
completion(nil, feed)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
if let image = cachedFeedImage(for: feed) {
|
2019-12-20 19:09:20 -08:00
|
|
|
completion(image, feed)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
loaders[feed] = Loader(url: url) { (result) in
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
defer {
|
|
|
|
self.loaders[feed] = nil
|
|
|
|
}
|
|
|
|
|
|
|
|
switch result {
|
|
|
|
case .success(let data):
|
|
|
|
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? AnyDictionary,
|
2021-08-11 21:57:49 -07:00
|
|
|
let base64 = dictionary[feed] as? String,
|
|
|
|
let imageData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters),
|
|
|
|
let image = UIImage(data: imageData) else {
|
2019-12-20 19:09:20 -08:00
|
|
|
completion(nil, feed)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
self.save(feedImage: image, for: feed)
|
2019-12-20 19:09:20 -08:00
|
|
|
completion(image, feed)
|
|
|
|
case .failure:
|
|
|
|
completion(nil, feed)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func storyImage(for identifier: String, imageURL: URL?, completion: @escaping ImageCompletion) {
|
|
|
|
guard let url = imageURL else {
|
|
|
|
completion(nil, identifier)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
if let image = cachedStoryImage(for: identifier) {
|
2019-12-20 19:09:20 -08:00
|
|
|
completion(image, identifier)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
loaders[identifier] = Loader(url: url) { (result) in
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
defer {
|
|
|
|
self.loaders[identifier] = nil
|
|
|
|
}
|
|
|
|
|
|
|
|
switch result {
|
|
|
|
case .success(let data):
|
|
|
|
guard let loadedImage = UIImage(data: data) else {
|
|
|
|
completion(nil, identifier)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-03-10 20:47:38 -07:00
|
|
|
let size = loadedImage.size
|
|
|
|
|
|
|
|
guard size.width >= 50, size.height >= 50 else {
|
|
|
|
completion(nil, identifier)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
let scaledImage = self.scale(image: loadedImage)
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
self.save(storyImage: scaledImage, for: identifier)
|
2019-12-20 19:09:20 -08:00
|
|
|
completion(scaledImage, identifier)
|
|
|
|
case .failure:
|
|
|
|
completion(nil, identifier)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
func cachedFeedImage(for feed: String) -> UIImage? {
|
|
|
|
if let image = feedImageCache[feed] {
|
|
|
|
return image
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let image = loadCachedImage(folderURL: feedImagesURL, identifier: feed) else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
feedImageCache[feed] = image
|
|
|
|
|
|
|
|
return image
|
|
|
|
}
|
|
|
|
|
|
|
|
func cachedStoryImage(for identifier: String) -> UIImage? {
|
|
|
|
if let image = storyImageCache[identifier] {
|
|
|
|
return image
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let image = loadCachedImage(folderURL: storyImagesURL, identifier: identifier) else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
storyImageCache[identifier] = image
|
|
|
|
|
|
|
|
return image
|
|
|
|
}
|
|
|
|
|
|
|
|
func loadCachedImage(folderURL: URL?, identifier: String) -> UIImage? {
|
|
|
|
guard let folderURL = folderURL else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
let imageURL = folderURL.appendingPathComponent(identifier).appendingPathExtension(Constant.imageExtension)
|
|
|
|
let data = try Data(contentsOf: imageURL)
|
|
|
|
|
|
|
|
guard let image = UIImage(data: data) else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return image
|
|
|
|
} catch {
|
|
|
|
print("Image error: \(error)")
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-12-20 19:09:20 -08:00
|
|
|
func scale(image: UIImage) -> UIImage {
|
2019-12-22 13:04:59 -08:00
|
|
|
let oldSize = image.size
|
|
|
|
|
2020-01-23 15:20:28 -08:00
|
|
|
guard oldSize.width > Constant.storyImageLimit || oldSize.height > Constant.storyImageLimit else {
|
2019-12-20 19:09:20 -08:00
|
|
|
return image
|
|
|
|
}
|
|
|
|
|
2019-12-22 13:04:59 -08:00
|
|
|
let scale: CGFloat
|
|
|
|
|
|
|
|
if oldSize.width < oldSize.height {
|
|
|
|
scale = Constant.storyImageSize / oldSize.width
|
|
|
|
} else {
|
|
|
|
scale = Constant.storyImageSize / oldSize.height
|
|
|
|
}
|
|
|
|
|
|
|
|
let newSize = CGSize(width: oldSize.width * scale, height: oldSize.height * scale)
|
2019-12-20 19:09:20 -08:00
|
|
|
|
2019-12-22 13:04:59 -08:00
|
|
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1)
|
2019-12-20 19:09:20 -08:00
|
|
|
|
2019-12-22 13:04:59 -08:00
|
|
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
2019-12-20 19:09:20 -08:00
|
|
|
|
|
|
|
defer {
|
|
|
|
UIGraphicsEndImageContext()
|
|
|
|
}
|
|
|
|
|
|
|
|
return UIGraphicsGetImageFromCurrentImageContext() ?? image
|
|
|
|
}
|
|
|
|
}
|