2023-01-03 14:09:22 +01:00
|
|
|
//
|
|
|
|
// https://mczachurski.dev
|
|
|
|
// Copyright © 2023 Marcin Czachurski and the repository contributors.
|
|
|
|
// Licensed under the MIT License.
|
|
|
|
//
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
import CoreData
|
2023-01-10 08:04:25 +01:00
|
|
|
import MastodonKit
|
2023-01-03 14:09:22 +01:00
|
|
|
|
|
|
|
public class TimelineService {
|
|
|
|
public static let shared = TimelineService()
|
2023-01-05 11:55:20 +01:00
|
|
|
private init() { }
|
2023-01-03 14:09:22 +01:00
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
public func onBottomOfList(for accountData: AccountData) async throws -> Int {
|
2023-01-03 14:09:22 +01:00
|
|
|
// Load data from API and operate on CoreData on background context.
|
|
|
|
let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
|
|
|
|
|
|
|
|
// Get maximimum downloaded stauts id.
|
2023-01-11 13:16:43 +01:00
|
|
|
let oldestStatus = StatusDataHandler.shared.getMinimumStatus(accountId: accountData.id, viewContext: backgroundContext)
|
2023-01-03 14:09:22 +01:00
|
|
|
|
|
|
|
guard let oldestStatus = oldestStatus else {
|
2023-01-11 13:16:43 +01:00
|
|
|
return 0
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
|
|
|
|
2023-01-20 13:47:38 +01:00
|
|
|
let statuses = try await self.loadData(for: accountData, on: backgroundContext, maxId: oldestStatus.id)
|
|
|
|
|
|
|
|
try backgroundContext.save()
|
|
|
|
return statuses.count
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
public func onTopOfList(for accountData: AccountData) async throws -> Int {
|
2023-01-03 14:09:22 +01:00
|
|
|
// Load data from API and operate on CoreData on background context.
|
|
|
|
let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
|
|
|
|
|
|
|
|
// Get maximimum downloaded stauts id.
|
2023-01-11 13:16:43 +01:00
|
|
|
let newestStatus = StatusDataHandler.shared.getMaximumStatus(accountId: accountData.id, viewContext: backgroundContext)
|
2023-01-03 20:42:20 +01:00
|
|
|
|
2023-01-20 16:57:25 +01:00
|
|
|
let newStatuses = try await self.loadData(for: accountData, on: backgroundContext, minId: newestStatus?.id)
|
|
|
|
try await self.clearOldStatuses(newStatuses: newStatuses, for: accountData, on: backgroundContext)
|
2023-01-20 13:47:38 +01:00
|
|
|
|
|
|
|
try backgroundContext.save()
|
2023-01-20 16:57:25 +01:00
|
|
|
return newStatuses.count
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
|
|
|
|
2023-01-14 08:52:51 +01:00
|
|
|
public func getComments(for statusId: String, and accountData: AccountData) async throws -> [CommentViewModel] {
|
|
|
|
var commentViewModels: [CommentViewModel] = []
|
|
|
|
|
|
|
|
let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accountData.accessToken ?? String.empty())
|
2023-01-15 14:11:48 +01:00
|
|
|
try await self.getCommentDescendants(for: statusId, client: client, showDivider: true, to: &commentViewModels)
|
2023-01-14 08:52:51 +01:00
|
|
|
|
|
|
|
return commentViewModels
|
|
|
|
}
|
|
|
|
|
2023-01-15 14:11:48 +01:00
|
|
|
private func getCommentDescendants(for statusId: String, client: MastodonClientAuthenticated, showDivider: Bool, to commentViewModels: inout [CommentViewModel]) async throws {
|
2023-01-14 08:52:51 +01:00
|
|
|
let context = try await client.getContext(for: statusId)
|
|
|
|
|
|
|
|
let descendants = context.descendants.toStatusViewModel()
|
|
|
|
for status in descendants {
|
2023-01-15 14:11:48 +01:00
|
|
|
commentViewModels.append(CommentViewModel(status: status, showDivider: showDivider))
|
|
|
|
|
|
|
|
if status.repliesCount > 0 {
|
|
|
|
try await self.getCommentDescendants(for: status.id, client: client, showDivider: false, to: &commentViewModels)
|
|
|
|
}
|
2023-01-14 08:52:51 +01:00
|
|
|
}
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
|
|
|
|
2023-01-20 16:57:25 +01:00
|
|
|
private func clearOldStatuses(newStatuses: [Status], for accountData: AccountData, on backgroundContext: NSManagedObjectContext) async throws {
|
2023-01-03 14:09:22 +01:00
|
|
|
guard let accessToken = accountData.accessToken else {
|
2023-01-20 13:47:38 +01:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Retrieve statuses from API.
|
|
|
|
let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accessToken)
|
|
|
|
let statuses = try await client.getHomeTimeline(limit: 40)
|
|
|
|
|
|
|
|
let dbStatuses = StatusDataHandler.shared.getAllStatuses(accountId: accountData.id)
|
|
|
|
|
|
|
|
var dbStatusesToRemove: [StatusData] = []
|
|
|
|
for dbStatus in dbStatuses {
|
|
|
|
if !statuses.contains(where: { status in status.id == dbStatus.id }) {
|
|
|
|
dbStatusesToRemove.append(dbStatus)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-20 16:57:25 +01:00
|
|
|
// Remove statuses that are not in 40 downloaded once.
|
2023-01-20 13:47:38 +01:00
|
|
|
if !dbStatusesToRemove.isEmpty {
|
|
|
|
StatusDataHandler.shared.remove(accountId: accountData.id, statuses: dbStatusesToRemove)
|
|
|
|
}
|
2023-01-20 16:57:25 +01:00
|
|
|
|
|
|
|
// Add statuses which are not existing in database, but has been downloaded via API.
|
|
|
|
var statusesToAdd: [Status] = []
|
|
|
|
for status in statuses {
|
|
|
|
if !dbStatuses.contains(where: { statusData in statusData.id == status.id }) &&
|
|
|
|
!newStatuses.contains(where: { newStatus in newStatus.id == status.id }) {
|
|
|
|
statusesToAdd.append(status)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save statuses in database (and download images).
|
|
|
|
if !statusesToAdd.isEmpty {
|
|
|
|
try await self.save(statuses: statusesToAdd, accountData: accountData, on: backgroundContext)
|
|
|
|
}
|
2023-01-20 13:47:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
private func loadData(for accountData: AccountData, on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws -> [Status] {
|
|
|
|
guard let accessToken = accountData.accessToken else {
|
|
|
|
return []
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
2023-01-04 17:56:01 +01:00
|
|
|
|
2023-01-03 14:09:22 +01:00
|
|
|
// Retrieve statuses from API.
|
|
|
|
let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accessToken)
|
2023-01-10 07:16:54 +01:00
|
|
|
let statuses = try await client.getHomeTimeline(maxId: maxId, minId: minId, limit: 20)
|
2023-01-20 16:57:25 +01:00
|
|
|
|
|
|
|
// Save statuses in database (and download images).
|
|
|
|
try await self.save(statuses: statuses, accountData: accountData, on: backgroundContext)
|
|
|
|
|
|
|
|
return statuses
|
|
|
|
}
|
|
|
|
|
|
|
|
public func updateStatus(_ statusData: StatusData, accountData: AccountData, basedOn status: Status) async throws -> StatusData? {
|
|
|
|
// Load data from API and operate on CoreData on background context.
|
|
|
|
let backgroundContext = CoreDataHandler.shared.newBackgroundContext()
|
|
|
|
|
|
|
|
// Download all images from server.
|
|
|
|
let attachmentsData = await self.fetchAllImages(statuses: [status])
|
|
|
|
|
|
|
|
// Update status data in database.
|
|
|
|
try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext)
|
|
|
|
try backgroundContext.save()
|
|
|
|
|
|
|
|
return statusData
|
|
|
|
}
|
|
|
|
|
|
|
|
private func save(statuses: [Status], accountData: AccountData, on backgroundContext: NSManagedObjectContext) async throws {
|
2023-01-10 07:16:54 +01:00
|
|
|
// Download all images from server.
|
|
|
|
let attachmentsData = await self.fetchAllImages(statuses: statuses)
|
|
|
|
|
2023-01-04 17:56:01 +01:00
|
|
|
// Save status data in database.
|
2023-01-03 14:09:22 +01:00
|
|
|
for status in statuses {
|
2023-01-10 07:16:54 +01:00
|
|
|
let contains = attachmentsData.contains { (key: String, value: Data) in
|
|
|
|
status.mediaAttachments.contains { attachment in
|
|
|
|
attachment.id == key
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// We are adding status only when we have at least one image for status.
|
|
|
|
if contains == false {
|
|
|
|
continue
|
|
|
|
}
|
2023-01-20 13:47:38 +01:00
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
guard let dbAccount = AccountDataHandler.shared.getAccountData(accountId: accountData.id, viewContext: backgroundContext) else {
|
|
|
|
throw DatabaseError.cannotDownloadAccount
|
|
|
|
}
|
|
|
|
|
2023-01-20 13:47:38 +01:00
|
|
|
let statusData = StatusDataHandler.shared.createStatusDataEntity(viewContext: backgroundContext)
|
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
statusData.pixelfedAccount = dbAccount
|
|
|
|
dbAccount.addToStatuses(statusData)
|
|
|
|
|
2023-01-10 07:16:54 +01:00
|
|
|
try await self.copy(from: status, to: statusData, attachmentsData: attachmentsData, on: backgroundContext)
|
2023-01-04 17:56:01 +01:00
|
|
|
}
|
2023-01-05 11:55:20 +01:00
|
|
|
}
|
|
|
|
|
2023-01-10 07:16:54 +01:00
|
|
|
private func copy(from status: Status, to statusData: StatusData, attachmentsData: Dictionary<String, Data>, on backgroundContext: NSManagedObjectContext) async throws {
|
2023-01-05 11:55:20 +01:00
|
|
|
statusData.copyFrom(status)
|
2023-01-04 17:56:01 +01:00
|
|
|
|
|
|
|
for attachment in status.mediaAttachments {
|
2023-01-10 07:16:54 +01:00
|
|
|
guard let imageData = attachmentsData[attachment.id] else {
|
2023-01-04 17:56:01 +01:00
|
|
|
continue
|
|
|
|
}
|
2023-01-03 14:09:22 +01:00
|
|
|
|
2023-01-04 17:56:01 +01:00
|
|
|
// Save attachment in database.
|
|
|
|
let attachmentData = statusData.attachments().first { item in item.id == attachment.id }
|
2023-01-08 14:50:37 +01:00
|
|
|
?? AttachmentDataHandler.shared.createAttachmnentDataEntity(viewContext: backgroundContext)
|
2023-01-04 17:56:01 +01:00
|
|
|
|
2023-01-05 11:55:20 +01:00
|
|
|
attachmentData.copyFrom(attachment)
|
2023-01-04 17:56:01 +01:00
|
|
|
attachmentData.statusId = statusData.id
|
|
|
|
attachmentData.data = imageData
|
|
|
|
|
2023-01-08 14:50:37 +01:00
|
|
|
// Read exif information.
|
|
|
|
if let exifProperties = imageData.getExifData() {
|
|
|
|
if let make = exifProperties.getExifValue("Make"), let model = exifProperties.getExifValue("Model") {
|
|
|
|
attachmentData.exifCamera = "\(make) \(model)"
|
|
|
|
}
|
|
|
|
|
|
|
|
// "Lens" or "Lens Model"
|
|
|
|
if let lens = exifProperties.getExifValue("Lens") {
|
|
|
|
attachmentData.exifLens = lens
|
|
|
|
}
|
|
|
|
|
|
|
|
if let createData = exifProperties.getExifValue("CreateDate") {
|
|
|
|
attachmentData.exifCreatedDate = createData
|
|
|
|
}
|
|
|
|
|
|
|
|
if let focalLenIn35mmFilm = exifProperties.getExifValue("FocalLenIn35mmFilm"),
|
|
|
|
let fNumber = exifProperties.getExifValue("FNumber")?.calculateExifNumber(),
|
|
|
|
let exposureTime = exifProperties.getExifValue("ExposureTime"),
|
|
|
|
let photographicSensitivity = exifProperties.getExifValue("PhotographicSensitivity") {
|
|
|
|
attachmentData.exifExposure = "\(focalLenIn35mmFilm)mm, f/\(fNumber), \(exposureTime)s, ISO \(photographicSensitivity)"
|
|
|
|
}
|
|
|
|
}
|
2023-01-05 11:55:20 +01:00
|
|
|
|
2023-01-04 17:56:01 +01:00
|
|
|
if attachmentData.isInserted {
|
|
|
|
attachmentData.statusRelation = statusData
|
|
|
|
statusData.addToAttachmentRelation(attachmentData)
|
2023-01-03 14:09:22 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-10 20:38:02 +01:00
|
|
|
public func fetchAllImages(statuses: [Status]) async -> Dictionary<String, Data> {
|
2023-01-10 07:16:54 +01:00
|
|
|
var attachmentUrls: Dictionary<String, URL> = [:]
|
|
|
|
|
|
|
|
statuses.forEach { status in
|
|
|
|
status.mediaAttachments.forEach { attachment in
|
|
|
|
attachmentUrls[attachment.id] = attachment.url
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return await withTaskGroup(of: (String, Data?).self, returning: [String : Data].self) { taskGroup in
|
|
|
|
for attachmentUrl in attachmentUrls {
|
|
|
|
taskGroup.addTask {
|
|
|
|
do {
|
|
|
|
if let imageData = try await self.fetchImage(attachmentUrl: attachmentUrl.value) {
|
|
|
|
return (attachmentUrl.key, imageData)
|
|
|
|
}
|
|
|
|
|
|
|
|
return (attachmentUrl.key, nil)
|
|
|
|
} catch {
|
2023-01-15 12:41:55 +01:00
|
|
|
ErrorService.shared.handle(error, message: "Fatching all images failed.")
|
2023-01-10 07:16:54 +01:00
|
|
|
return (attachmentUrl.key, nil)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var childTaskResults = [String: Data]()
|
|
|
|
for await result in taskGroup {
|
|
|
|
guard let data = result.1 else {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
childTaskResults[result.0] = data
|
|
|
|
}
|
|
|
|
|
|
|
|
return childTaskResults
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func fetchImage(attachmentUrl: URL) async throws -> Data? {
|
|
|
|
guard let data = try await RemoteFileService.shared.fetchData(url: attachmentUrl) else {
|
2023-01-03 14:09:22 +01:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
}
|