Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for collections in trash #1013

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
94 changes: 86 additions & 8 deletions Zotero.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

61 changes: 0 additions & 61 deletions Zotero/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,44 +58,6 @@ final class AppDelegate: UIResponder {
UserDefaults.standard.removeObject(forKey: "ItemsSortType")
}

/// This migration was created to move from "old" file structure (before build 120) to "new" one, where items are stored with their proper filenames.
/// In `DidMigrateFileStructure` all downloaded items were moved. Items which were up for upload were forgotten, so `DidMigrateFileStructure2` was added to migrate also these items.
/// TODO: - Remove after beta
private func migrateFileStructure(queue: DispatchQueue) {
let didMigrateFileStructure = UserDefaults.standard.bool(forKey: "DidMigrateFileStructure")
let didMigrateFileStructure2 = UserDefaults.standard.bool(forKey: "DidMigrateFileStructure2")

guard !didMigrateFileStructure || !didMigrateFileStructure2 else { return }

guard let dbStorage = self.controllers.userControllers?.dbStorage else {
// If user is logget out, no need to migrate, DB is empty and files should be gone.
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure")
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2")
return
}

// Migrate file structure
if !didMigrateFileStructure && !didMigrateFileStructure2 {
if let items = try? self.readAttachmentTypes(for: ReadAllDownloadedAndForUploadItemsDbRequest(), dbStorage: dbStorage, queue: queue) {
self.migrateFileStructure(for: items)
}
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure")
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2")
} else if !didMigrateFileStructure {
if let items = try? self.readAttachmentTypes(for: ReadAllDownloadedItemsDbRequest(), dbStorage: dbStorage, queue: queue) {
self.migrateFileStructure(for: items)
}
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure")
} else if !didMigrateFileStructure2 {
if let items = try? self.readAttachmentTypes(for: ReadAllItemsForUploadDbRequest(), dbStorage: dbStorage, queue: queue) {
self.migrateFileStructure(for: items)
}
UserDefaults.standard.setValue(true, forKey: "DidMigrateFileStructure2")
}

NotificationCenter.default.post(name: .forceReloadItems, object: nil)
}

private func readAttachmentTypes<Request: DbResponseRequest>(for request: Request, dbStorage: DbStorage, queue: DispatchQueue) throws -> [(String, LibraryIdentifier, Attachment.Kind)] where Request.Response == Results<RItem> {
var types: [(String, LibraryIdentifier, Attachment.Kind)] = []

Expand All @@ -113,28 +75,6 @@ final class AppDelegate: UIResponder {
return types
}

private func migrateFileStructure(for items: [(String, LibraryIdentifier, Attachment.Kind)]) {
for (key, libraryId, type) in items {
switch type {
case .url: break
case .file(_, _, _, let linkType, _) where (linkType == .embeddedImage || linkType == .linkedFile): break // Embedded images and linked files don't need to be checked.
case .file(let filename, let contentType, _, let linkType, _):
// Snapshots were stored based on new structure, no need to do anything.
guard linkType != .importedUrl || contentType != "text/html" else { continue }

let filenameParts = filename.split(separator: ".")
let oldFile: File
if filenameParts.count > 1, let ext = filenameParts.last.flatMap(String.init) {
oldFile = FileData(rootPath: Files.appGroupPath, relativeComponents: ["downloads", libraryId.folderName], name: key, ext: ext)
} else {
oldFile = FileData(rootPath: Files.appGroupPath, relativeComponents: ["downloads", libraryId.folderName], name: key, contentType: contentType)
}
let newFile = Files.attachmentFile(in: libraryId, key: key, filename: filename, contentType: contentType)
try? self.controllers.fileStorage.move(from: oldFile, to: newFile)
}
}
}

private func removeFinishedUploadFiles(queue: DispatchQueue) {
let didDeleteFiles = UserDefaults.standard.bool(forKey: "DidDeleteFinishedUploadFiles")

Expand Down Expand Up @@ -287,7 +227,6 @@ extension AppDelegate: UIApplicationDelegate {

let queue = DispatchQueue(label: "org.zotero.AppDelegateMigration", qos: .userInitiated)
queue.async {
self.migrateFileStructure(queue: queue)
self.removeFinishedUploadFiles(queue: queue)
self.updateCreatorSummaryFormat(queue: queue)
}
Expand Down
1 change: 1 addition & 0 deletions Zotero/Assets/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@
"accessibility.items.share" = "Share selected items";
"accessibility.items.download_attachments" = "Download attachments for selected items";
"accessibility.items.remove_downloads" = "Remove downloads for selected items";
"accessibility.items.collection" = "Collection";
"accessibility.item_detail.download_and_open" = "Double tap to download and open";
"accessibility.item_detail.open" = "Double tap to open";
"accessibility.pdf.sidebar_open" = "Open sidebar";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ final class AttachmentDownloader: NSObject {
case ready(compressed: Bool?)
case failed(Swift.Error)
case cancelled

var isProgress: Bool {
switch self {
case .progress:
return true

case .ready, .failed, .cancelled:
return false
}
}
}

let key: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ struct EmptyTrashDbRequest: DbRequest {
var needsWrite: Bool { return true }

func process(in database: Realm) throws {
database.objects(RItem.self).filter(.items(for: .custom(.trash), libraryId: self.libraryId)).forEach {
database.objects(RItem.self).filter(.items(for: .custom(.trash), libraryId: libraryId)).forEach {
$0.deleted = true
$0.changeType = .user
}
database.objects(RCollection.self).filter(.trashedCollections(in: libraryId)).forEach {
$0.deleted = true
$0.changeType = .user
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// MarkCollectionsAsTrashedDbRequest.swift
// Zotero
//
// Created by Michal Rentka on 18.07.2024.
// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved.
//

import Foundation

import RealmSwift

struct MarkCollectionsAsTrashedDbRequest: DbRequest {
let keys: [String]
let libraryId: LibraryIdentifier
let trashed: Bool

var needsWrite: Bool { return true }

func process(in database: Realm) throws {
let collections = database.objects(RCollection.self).filter(.keys(self.keys, in: self.libraryId))
collections.forEach { item in
item.trash = trashed
item.changeType = .user
item.changes.append(RObjectChange.create(changes: RCollectionChanges.trash))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ struct ReadCollectionsDbRequest: DbResponseRequest {

let libraryId: LibraryIdentifier
let excludedKeys: Set<String>
let trash: Bool

var needsWrite: Bool { return false }

init(libraryId: LibraryIdentifier, excludedKeys: Set<String> = []) {
init(libraryId: LibraryIdentifier, trash: Bool = false, excludedKeys: Set<String> = []) {
self.libraryId = libraryId
self.trash = trash
self.excludedKeys = excludedKeys
}

func process(in database: Realm) throws -> Results<RCollection> {
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.notSyncState(.dirty, in: self.libraryId),
.deleted(false),
.isTrash(false),
.isTrash(trash),
.key(notIn: self.excludedKeys)])
return database.objects(RCollection.self).filter(predicate)
}
Expand Down
2 changes: 1 addition & 1 deletion Zotero/Controllers/IdentifierLookupController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ final class IdentifierLookupController {

setupObservers()
}

// MARK: Actions
func initialize(libraryId: LibraryIdentifier, collectionKeys: Set<String>, completion: @escaping ([LookupData]?) -> Void) {
accessQueue.async(flags: .barrier) { [weak self] in
Expand Down
2 changes: 2 additions & 0 deletions Zotero/Extensions/Localizable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ internal enum L10n {
internal enum Items {
/// Add selected items to collection
internal static let addToCollection = L10n.tr("Localizable", "accessibility.items.add_to_collection", fallback: "Add selected items to collection")
/// Collection
internal static let collection = L10n.tr("Localizable", "accessibility.items.collection", fallback: "Collection")
/// Delete selected items
internal static let delete = L10n.tr("Localizable", "accessibility.items.delete", fallback: "Delete selected items")
/// Deselect All Items
Expand Down
27 changes: 27 additions & 0 deletions Zotero/Extensions/OrderedDictionary+Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// OrderedDictionary+Utils.swift
// Zotero
//
// Created by Michal Rentka on 19.07.2024.
// Copyright © 2024 Corporation for Digital Scholarship. All rights reserved.
//

import OrderedCollections

extension OrderedDictionary {
/// Finds an insertion index for given element in array. The array has to be sorted! Implemented as binary search.
/// - parameter element: Element to be found/inserted
/// - parameter areInIncreasingOrder: sorting function to be used to compare elements in array.
/// - returns: Insertion index into sorted array.
func index(of element: Value, sortedBy areInIncreasingOrder: (Value, Value) -> Bool) -> Int {
var (low, high) = (0, self.count - 1)
while low <= high {
switch (low + high) / 2 {
case let mid where areInIncreasingOrder(element, self.values[mid]): high = mid - 1
case let mid where areInIncreasingOrder(self.values[mid], element): low = mid + 1
case let mid: return mid // element found at mid
}
}
return low // element not found, should be inserted here
}
}
5 changes: 3 additions & 2 deletions Zotero/Models/Database/RCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ struct RCollectionChanges: OptionSet {
extension RCollectionChanges {
static let name = RCollectionChanges(rawValue: 1 << 0)
static let parent = RCollectionChanges(rawValue: 1 << 1)
static let all: RCollectionChanges = [.name, .parent]
static let trash = RCollectionChanges(rawValue: 1 << 2)
static let all: RCollectionChanges = [.name, .parent, .trash]
}

final class RCollection: Object {
static let observableKeypathsForList: [String] = ["name", "parentKey", "items"]
static let observableKeypathsForList: [String] = ["name", "parentKey", "items", "trash"]

@Persisted(indexed: true) var key: String
@Persisted var name: String
Expand Down
1 change: 0 additions & 1 deletion Zotero/Models/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@ extension Notification.Name {
static let attachmentFileDeleted = Notification.Name("org.zotero.AttachmentFileDeleted")
// Sent when attachment (`RItem`) is completely removed from the app (not just trashed). Used to remove attachment files of deleted attachments.
static let attachmentDeleted = Notification.Name(rawValue: "org.zotero.AttachmentsDeleted")
static let forceReloadItems = Notification.Name(rawValue: "org.zotero.ForceReloadItems")
}
5 changes: 5 additions & 0 deletions Zotero/Models/Predicates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ extension NSPredicate {
return NSPredicate(format: "trash = %@", NSNumber(booleanLiteral: trash))
}

static func trashedCollections(in libraryId: LibraryIdentifier) -> NSPredicate {
let predicates: [NSPredicate] = [.library(with: libraryId), .deleted(false), .isTrash(true), .notSyncState(.dirty)]
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}

private static func baseItemPredicates(isTrash: Bool, libraryId: LibraryIdentifier) -> [NSPredicate] {
var predicates: [NSPredicate] = [.library(with: libraryId), .notSyncState(.dirty), .deleted(false), .isTrash(isTrash)]
if !isTrash {
Expand Down
16 changes: 9 additions & 7 deletions Zotero/Models/UpdatableObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,28 +51,30 @@ extension Updatable {

extension RCollection: Updatable {
var updateParameters: [String: Any]? {
guard self.isChanged else { return nil }
guard isChanged else { return nil }

var parameters: [String: Any] = ["key": self.key,
"version": self.version]
var parameters: [String: Any] = ["key": key, "version": version]

let changes = self.changedFields
let changes = changedFields
if changes.contains(.name) {
parameters["name"] = self.name
parameters["name"] = name
}
if changes.contains(.parent) {
if let key = self.parentKey {
if let key = parentKey {
parameters["parentCollection"] = key
} else {
parameters["parentCollection"] = false
}
}
if changes.contains(.trash) {
parameters["deleted"] = trash
}

return parameters
}

var selfOrChildChanged: Bool {
return self.isChanged
return isChanged
}

func markAsChanged(in database: Realm) {
Expand Down
Loading