mirror of
https://github.com/samuelclay/NewsBlur.git
synced 2025-08-05 16:58:59 +00:00

- Updated project to Xcode 14. - Updated Swift code to Swift 5.7. - Tweaked the login view to avoid the logo behind the Dynamic Island on iPhone 14 Pro.
362 lines
11 KiB
Swift
362 lines
11 KiB
Swift
//
|
|
// WidgetCache.swift
|
|
// Widget Extension
|
|
//
|
|
// Created by David Sinclair on 2021-08-07.
|
|
// Copyright © 2021 NewsBlur. All rights reserved.
|
|
//
|
|
|
|
import WidgetKit
|
|
import UIKit
|
|
|
|
enum WidgetCacheError: String, Error {
|
|
case notLoggedIn
|
|
case loading
|
|
case noFeeds
|
|
case noStories
|
|
}
|
|
|
|
class WidgetCache {
|
|
/// The base URL of the NewsBlur server.
|
|
var host: String?
|
|
|
|
/// The secret token for authentication.
|
|
var token: String?
|
|
|
|
/// The preview size and position.
|
|
enum Preview: String {
|
|
case none
|
|
case smallLeft
|
|
case largeLeft
|
|
case largeRight
|
|
case smallRight
|
|
}
|
|
|
|
/// The preview size and position.
|
|
var preview: Preview = .largeRight
|
|
|
|
/// An array of feeds to load.
|
|
var feeds = [Feed]()
|
|
|
|
/// Loaded stories.
|
|
var stories = [Story]()
|
|
|
|
/// An error to display instead of the stories, or `nil` if the stories should be displayed.
|
|
var error: WidgetCacheError?
|
|
|
|
typealias CacheCompletion = () -> Void
|
|
|
|
private var cacheCompletion: CacheCompletion?
|
|
|
|
private typealias AnyDictionary = [String : Any]
|
|
|
|
private typealias ImageDictionary = [String : UIImage]
|
|
|
|
private var feedImageCache = ImageDictionary()
|
|
|
|
private typealias LoaderDictionary = [String : Loader]
|
|
|
|
private var loaders = LoaderDictionary()
|
|
|
|
struct Constant {
|
|
static let group = "group.com.newsblur.NewsBlur-Group"
|
|
static let token = "share:token"
|
|
static let host = "share:host"
|
|
static let feeds = "widget:feeds_array"
|
|
static let preview = "widget:preview_images_size"
|
|
static let previewNone = "none"
|
|
static let previewSmallLeft = "small_left"
|
|
static let previewLargeLeft = "large_left"
|
|
static let previewLargeRight = "large_right"
|
|
static let previewSmallRight = "small_right"
|
|
static let widgetFolder = "Widget"
|
|
static let storiesFilename = "Stories.json"
|
|
static let feedImagesFilename = "Feed Images"
|
|
static let imageExtension = "png"
|
|
static let limit = 6
|
|
static let defaultRowHeight: CGFloat = 110
|
|
static let thumbnailHiddenConstant: CGFloat = -50
|
|
static let thumbnailShownConstant: CGFloat = 20
|
|
}
|
|
|
|
func load(completionHandler: @escaping CacheCompletion) {
|
|
let feedIds = feeds.map { $0.id }
|
|
let combinedFeeds = feedIds.joined(separator: "&f=")
|
|
|
|
guard let url = hostURL(with: "/reader/river_stories_widget/?include_hidden=false&replace_hidden_stories=true&thumbnail_size=192&page=1&infrequent=false&order=newest&read_filter=unread&limit=\(Constant.limit)&f=\(combinedFeeds)") else {
|
|
error = .loading
|
|
completionHandler()
|
|
return
|
|
}
|
|
|
|
error = nil
|
|
cacheCompletion = completionHandler
|
|
loaders[Constant.storiesFilename] = Loader(url: url, completion: storyLoaderCompletion(result:))
|
|
}
|
|
|
|
func cleaned(_ string: String) -> String {
|
|
let clean = string.prefix(1000).replacingOccurrences(of: "\n", with: "")
|
|
.replacingOccurrences(of: "<[^>]+>|&[^;]+;", with: " ", options: .regularExpression, range: nil)
|
|
.trimmingCharacters(in: .whitespaces)
|
|
|
|
return clean.isEmpty ? " " : clean
|
|
}
|
|
|
|
func hostURL(with path: String) -> URL? {
|
|
guard let host else {
|
|
return nil
|
|
}
|
|
|
|
if let token {
|
|
return URL(string: host + path + "&secret_token=\(token)")
|
|
} else {
|
|
return URL(string: host + path)
|
|
}
|
|
}
|
|
|
|
func storyLoaderCompletion(result: Result<Data, Error>) {
|
|
defer {
|
|
cacheCompletion = nil
|
|
loaders[Constant.storiesFilename] = nil
|
|
}
|
|
|
|
if case .failure = result {
|
|
error = .loading
|
|
cacheCompletion?()
|
|
|
|
return
|
|
}
|
|
|
|
guard case .success(let data) = result else {
|
|
return
|
|
}
|
|
|
|
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? AnyDictionary else {
|
|
error = .loading
|
|
cacheCompletion?()
|
|
|
|
return
|
|
}
|
|
|
|
guard let storyArray = dictionary["stories"] as? [AnyDictionary] else {
|
|
error = .loading
|
|
cacheCompletion?()
|
|
|
|
return
|
|
}
|
|
|
|
stories.removeAll()
|
|
|
|
for storyDict in storyArray {
|
|
stories.append(Story(from: storyDict))
|
|
}
|
|
|
|
saveStories()
|
|
|
|
if stories.isEmpty, error == nil {
|
|
error = .noStories
|
|
}
|
|
|
|
// Keep a local copy, since the property will be cleared before the async closure is called.
|
|
let localCompletion = cacheCompletion
|
|
|
|
DispatchQueue.main.async {
|
|
localCompletion?()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
var feedImagesURL: URL? {
|
|
return widgetFolderURL?.appendingPathComponent(Constant.feedImagesFilename)
|
|
}
|
|
|
|
func createWidgetFolder(url: URL? = nil) {
|
|
guard let url = url ?? widgetFolderURL else {
|
|
return
|
|
}
|
|
|
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
|
}
|
|
|
|
func stories(count: Int) -> [Story] {
|
|
return Array(stories.prefix(count))
|
|
}
|
|
|
|
func feed(for story: Story) -> Feed? {
|
|
return feeds.first(where: { $0.id == story.feed })
|
|
}
|
|
|
|
func loadCachedStories() {
|
|
feeds = []
|
|
stories = []
|
|
|
|
guard let defaults = UserDefaults.init(suiteName: Constant.group) else {
|
|
return
|
|
}
|
|
|
|
host = defaults.string(forKey: Constant.host)
|
|
token = defaults.string(forKey: Constant.token)
|
|
|
|
if let previewString = defaults.string(forKey: Constant.preview) {
|
|
switch previewString {
|
|
case Constant.previewNone:
|
|
preview = .none
|
|
case Constant.previewSmallLeft:
|
|
preview = .smallLeft
|
|
case Constant.previewLargeLeft:
|
|
preview = .largeLeft
|
|
case Constant.previewSmallRight:
|
|
preview = .smallRight
|
|
default:
|
|
preview = .largeRight
|
|
}
|
|
}
|
|
|
|
if let array = defaults.array(forKey: Constant.feeds) as? [Feed.Dictionary] {
|
|
feeds = array.map { Feed(from: $0) }
|
|
}
|
|
|
|
guard let url = storiesURL else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let json = try Data(contentsOf: url)
|
|
let decoder = JSONDecoder()
|
|
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
decoder.dataDecodingStrategy = .base64
|
|
|
|
stories = try decoder.decode([Story].self, from: json)
|
|
} catch {
|
|
NSLog("Error \(error)")
|
|
}
|
|
}
|
|
|
|
func saveStories() {
|
|
guard let url = storiesURL else {
|
|
return
|
|
}
|
|
|
|
createWidgetFolder()
|
|
|
|
let encoder = JSONEncoder()
|
|
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
encoder.dataEncodingStrategy = .base64
|
|
encoder.outputFormatting = .prettyPrinted
|
|
|
|
do {
|
|
let json = try encoder.encode(stories)
|
|
|
|
try json.write(to: url)
|
|
} catch {
|
|
NSLog("Error \(error)")
|
|
}
|
|
}
|
|
|
|
func save(feedImage: UIImage, for identifier: String) {
|
|
feedImageCache[identifier] = feedImage
|
|
save(image: feedImage, to: feedImagesURL, for: identifier)
|
|
}
|
|
|
|
func save(image: UIImage, to folderURL: URL?, for identifier: String) {
|
|
guard let folderURL else {
|
|
return
|
|
}
|
|
|
|
createWidgetFolder(url: folderURL)
|
|
|
|
do {
|
|
let imageURL = folderURL.appendingPathComponent(identifier).appendingPathExtension(Constant.imageExtension)
|
|
|
|
try image.pngData()?.write(to: imageURL)
|
|
} catch {
|
|
NSLog("Image saving error: \(error)")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if let image = cachedFeedImage(for: feed) {
|
|
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,
|
|
let base64 = dictionary[feed] as? String,
|
|
let imageData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters),
|
|
let image = UIImage(data: imageData) else {
|
|
completion(nil, feed)
|
|
return
|
|
}
|
|
|
|
self.save(feedImage: image, for: feed)
|
|
completion(image, feed)
|
|
case .failure:
|
|
completion(nil, feed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 loadCachedImage(folderURL: URL?, identifier: String) -> UIImage? {
|
|
guard let 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 {
|
|
NSLog("Cached image loading error: \(error)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|