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 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
51974bf
Move deleted collections to trash instead of deleting them immediately
michalrentka Jul 18, 2024
29348a7
WIP: trash controller
michalrentka Jul 19, 2024
14f6b45
WIP
michalrentka Sep 17, 2024
6502e39
WIP: Trash items controller
michalrentka Sep 18, 2024
a26b399
WIP: refactoring items table view handler & data source
michalrentka Sep 19, 2024
0ced389
WIP: Items controller refactoring to simplify trash controller
michalrentka Sep 20, 2024
182d058
Implementing items actions
michalrentka Sep 23, 2024
b94ebe4
WIP: Implementing sorting / filtering
michalrentka Sep 24, 2024
52c0825
WIP: added remaining actions, added download attachment
michalrentka Sep 26, 2024
d397c76
WIP: added download/remove download compatibility
michalrentka Sep 27, 2024
487c06f
WIP: Added observing to trash controller
michalrentka Sep 30, 2024
2ec3aa2
last fixes
michalrentka Oct 1, 2024
d46abea
Fixed changed comment
michalrentka Oct 16, 2024
6e4560c
Simplified switch
michalrentka Oct 16, 2024
c352b0f
Update Zotero/Scenes/Detail/DetailCoordinator.swift
michalrentka Oct 16, 2024
692a451
Update Zotero/Scenes/Detail/Trash/Models/TrashObject.swift
michalrentka Oct 16, 2024
7afe693
Retain library token
michalrentka Oct 16, 2024
a44d18c
Fixed typos
michalrentka Oct 16, 2024
97b82f0
Simplified items controller creation
michalrentka Oct 16, 2024
a32a8ce
WIP: Refactoring trash state & handler to optimise object loading
michalrentka Oct 22, 2024
c4b5855
WIP: finishing trash state data refactoring
michalrentka Oct 23, 2024
0f37522
Bug fixes
michalrentka Oct 24, 2024
daa13eb
Fixed broken filters
michalrentka Oct 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 90 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 @@ -539,6 +539,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 @@ -24,6 +24,7 @@ struct CreateCollectionDbRequest: DbRequest {
collection.name = self.name
collection.syncState = .synced
collection.libraryId = self.libraryId
collection.updateSortName()

var changes: RCollectionChanges = .name

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct EditCollectionDbRequest: DbRequest {

if collection.name != self.name {
collection.name = self.name
collection.updateSortName()
changes.insert(.name)
}

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,19 +15,30 @@ struct ReadCollectionsDbRequest: DbResponseRequest {

let libraryId: LibraryIdentifier
let excludedKeys: Set<String>
let trash: Bool
let searchTextComponents: [String]

var needsWrite: Bool { return false }

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

func process(in database: Realm) throws -> Results<RCollection> {
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.notSyncState(.dirty, in: self.libraryId),
.deleted(false),
.isTrash(false),
.key(notIn: self.excludedKeys)])
return database.objects(RCollection.self).filter(predicate)
var predicates: [NSPredicate] = [
.notSyncState(.dirty, in: libraryId),
.deleted(false),
.isTrash(trash),
.key(notIn: excludedKeys)
]
if !searchTextComponents.isEmpty {
for component in searchTextComponents {
predicates.append(NSPredicate(format: "name contains[c] %@", component))
}
}
return database.objects(RCollection.self).filter(NSCompoundPredicate(andPredicateWithSubpredicates: predicates))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ struct StoreCollectionsDbRequest: DbRequest {
collection.trash = response.data.isTrash
collection.trashDate = collection.trash ? Date.now : nil
}
collection.updateSortName()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to check for collection.name != response.data.name and only then update sort name, similar to EditCollectionDbRequest? May need to do so only for existing collections.


self.sync(parentCollection: response.data.parentCollection, libraryId: libraryId, collection: collection, database: database)
}
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems not used anymore, it can be removed.

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
}
}
23 changes: 22 additions & 1 deletion Zotero/Models/Database/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import RealmSwift
import Network

struct Database {
private static let schemaVersion: UInt64 = 47
private static let schemaVersion: UInt64 = 49

static func mainConfiguration(url: URL, fileStorage: FileStorage) -> Realm.Configuration {
var config = Realm.Configuration(
Expand Down Expand Up @@ -92,6 +92,27 @@ struct Database {
if schemaVersion < 47 {
setTrashDates(migration: migration)
}
if schemaVersion < 49 {
migrateCollectionsSortName(migration: migration)
migrateTagNames(migration: migration)
migrateItemSortTitle(migration: migration)
}
}
}

private static func migrateItemSortTitle(migration: Migration) {
migration.enumerateObjects(ofType: RItem.className()) { oldObject, newObject in
if let title = oldObject?["displayTitle"] as? String, !title.isEmpty {
newObject?["sortTitle"] = RItem.sortTitle(from: title)
}
}
}

private static func migrateCollectionsSortName(migration: Migration) {
migration.enumerateObjects(ofType: RCollection.className()) { oldObject, newObject in
if let name = oldObject?["name"] as? String, !name.isEmpty {
newObject?["sortName"] = RCollection.sortName(from: name)
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions Zotero/Models/Database/RCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class RCollection: Object {

@Persisted(indexed: true) var key: String
@Persisted var name: String
@Persisted var sortName: String
@Persisted var dateModified: Date
@Persisted var parentKey: String?
@Persisted var collapsed: Bool = true
Expand Down Expand Up @@ -60,6 +61,17 @@ final class RCollection: Object {
/// Indicates whether the object is trashed locally and needs to be synced with backend
@Persisted var trash: Bool

static func sortName(from name: String) -> String {
return name.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased()
}

func updateSortName() {
let newName = RCollection.sortName(from: name)
if newName != sortName {
sortName = newName
}
}

// MARK: - Sync properties

var changedFields: RCollectionChanges {
Expand Down
10 changes: 7 additions & 3 deletions Zotero/Models/Database/RItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,14 @@ final class RItem: Object {
self.updateSortTitle()
}

static func sortTitle(from title: String) -> String {
return title.strippedRichTextTags.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased()
}

private func updateSortTitle() {
let newTitle = self.displayTitle.strippedRichTextTags.trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased()
if newTitle != self.sortTitle {
self.sortTitle = newTitle
let newTitle = RItem.sortTitle(from: displayTitle)
if newTitle != sortTitle {
sortTitle = newTitle
}
}

Expand Down
2 changes: 1 addition & 1 deletion Zotero/Models/Database/RTag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class RTag: Object {
}

static func sortName(from name: String) -> String {
return name.trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased()
return name.folding(options: .diacriticInsensitive, locale: .current).trimmingCharacters(in: CharacterSet(charactersIn: "[]'\"")).lowercased()
}

static func create(name: String, color: String? = nil, libraryId: LibraryIdentifier, order: Int? = nil) -> RTag {
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")
}
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