diff --git a/Zotero.xcodeproj/project.pbxproj b/Zotero.xcodeproj/project.pbxproj index fda4d79db..eb807d82d 100644 --- a/Zotero.xcodeproj/project.pbxproj +++ b/Zotero.xcodeproj/project.pbxproj @@ -698,6 +698,9 @@ B36459E926441A2000A0C2C0 /* TagRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = B36459E826441A2000A0C2C0 /* TagRow.xib */; }; B367330D24ACB63300E0CDA8 /* HtmlAttributedStringConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B367330C24ACB63300E0CDA8 /* HtmlAttributedStringConverter.swift */; }; B36A988D2428E05A005D5790 /* TranslatorsAndStylesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36A988C2428E059005D5790 /* TranslatorsAndStylesController.swift */; }; + B36B8A542A4C2E930038BA1C /* test_annotation_dictionary_position.json in Resources */ = {isa = PBXBuildFile; fileRef = B36B8A512A4C2E930038BA1C /* test_annotation_dictionary_position.json */; }; + B36B8A552A4C2E930038BA1C /* test_annotation_array_position.json in Resources */ = {isa = PBXBuildFile; fileRef = B36B8A522A4C2E930038BA1C /* test_annotation_array_position.json */; }; + B36B8A562A4C2E930038BA1C /* test_annotation_basic_position.json in Resources */ = {isa = PBXBuildFile; fileRef = B36B8A532A4C2E930038BA1C /* test_annotation_basic_position.json */; }; B36BEC7324485FC700A60552 /* TagColorGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36BEC7224485FC700A60552 /* TagColorGenerator.swift */; }; B36C07DE26FB264800C855A9 /* UITableView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36C07DD26FB264800C855A9 /* UITableView+Extensions.swift */; }; B36C5086257521DE00A370D3 /* AnnotationEditActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36C5085257521DE00A370D3 /* AnnotationEditActionHandler.swift */; }; @@ -1092,6 +1095,9 @@ B3F49FB228B8C3CB00A1F3E8 /* DocumentAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F49FB128B8C3CB00A1F3E8 /* DocumentAnnotation.swift */; }; B3F49FB428B8C3D900A1F3E8 /* DatabaseAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F49FB328B8C3D900A1F3E8 /* DatabaseAnnotation.swift */; }; B3F49FB628B8C3F600A1F3E8 /* AnnotationEditability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F49FB528B8C3F600A1F3E8 /* AnnotationEditability.swift */; }; + B3F4E4EC2A4DAA7D00820718 /* UpdatableObjectSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F4E4EB2A4DAA7D00820718 /* UpdatableObjectSpec.swift */; }; + B3F4E4EE2A4DC39800820718 /* JSONSerialization+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F4E4ED2A4DC39800820718 /* JSONSerialization+Utils.swift */; }; + B3F4E4EF2A4DC4E300820718 /* JSONSerialization+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F4E4ED2A4DC39800820718 /* JSONSerialization+Utils.swift */; }; B3F4F2A12728015700685E1A /* MarkAttachmentsNotUploadedDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F4F2A02728015700685E1A /* MarkAttachmentsNotUploadedDbRequest.swift */; }; B3F55A1729EED04700A6716E /* ReadFilteredTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F55A1629EED04700A6716E /* ReadFilteredTagsDbRequest.swift */; }; B3F55A1929EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F55A1829EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift */; }; @@ -1643,6 +1649,9 @@ B36459E826441A2000A0C2C0 /* TagRow.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TagRow.xib; sourceTree = ""; }; B367330C24ACB63300E0CDA8 /* HtmlAttributedStringConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlAttributedStringConverter.swift; sourceTree = ""; }; B36A988C2428E059005D5790 /* TranslatorsAndStylesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslatorsAndStylesController.swift; sourceTree = ""; }; + B36B8A512A4C2E930038BA1C /* test_annotation_dictionary_position.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test_annotation_dictionary_position.json; sourceTree = ""; }; + B36B8A522A4C2E930038BA1C /* test_annotation_array_position.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test_annotation_array_position.json; sourceTree = ""; }; + B36B8A532A4C2E930038BA1C /* test_annotation_basic_position.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = test_annotation_basic_position.json; sourceTree = ""; }; B36BEC7224485FC700A60552 /* TagColorGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagColorGenerator.swift; sourceTree = ""; }; B36C07DD26FB264800C855A9 /* UITableView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Extensions.swift"; sourceTree = ""; }; B36C5085257521DE00A370D3 /* AnnotationEditActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationEditActionHandler.swift; sourceTree = ""; }; @@ -1973,6 +1982,8 @@ B3F49FB128B8C3CB00A1F3E8 /* DocumentAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentAnnotation.swift; sourceTree = ""; }; B3F49FB328B8C3D900A1F3E8 /* DatabaseAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseAnnotation.swift; sourceTree = ""; }; B3F49FB528B8C3F600A1F3E8 /* AnnotationEditability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnotationEditability.swift; sourceTree = ""; }; + B3F4E4EB2A4DAA7D00820718 /* UpdatableObjectSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatableObjectSpec.swift; sourceTree = ""; }; + B3F4E4ED2A4DC39800820718 /* JSONSerialization+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONSerialization+Utils.swift"; sourceTree = ""; }; B3F4F2A02728015700685E1A /* MarkAttachmentsNotUploadedDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAttachmentsNotUploadedDbRequest.swift; sourceTree = ""; }; B3F55A1629EED04700A6716E /* ReadFilteredTagsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadFilteredTagsDbRequest.swift; sourceTree = ""; }; B3F55A1829EED4CB00A6716E /* ReadAutomaticTagsDbRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadAutomaticTagsDbRequest.swift; sourceTree = ""; }; @@ -2373,6 +2384,7 @@ B3AB43A727E8A68D006F3E4E /* Dictionary+Extensions.swift */, B3129854270EE9DE002376F9 /* Error+Helpers.swift */, B305650623FC051E003304F2 /* FileManager+Utils.swift */, + B3F4E4ED2A4DC39800820718 /* JSONSerialization+Utils.swift */, B337A5AB244F228500AFD13D /* Localizable.swift */, B305650723FC051E003304F2 /* MD5+Url.swift */, B3CBB120248A439A00C4228F /* Notification+Extensions.swift */, @@ -2641,6 +2653,7 @@ B34F274E220E20370038B3B1 /* SyncControllerSpec.swift */, B3202C6427103DEE00485BE4 /* TestControllers.swift */, B3F47C00243B339F004F8B1E /* TranslatorsControllerSpec.swift */, + B3F4E4EB2A4DAA7D00820718 /* UpdatableObjectSpec.swift */, B3202C6B271048FF00485BE4 /* WebDavControllerSpec.swift */, B31DDAA02729A7DC002CFA05 /* WebDavCredentials.swift */, ); @@ -2767,9 +2780,11 @@ B3B1EDF22502455F00D8BC1E /* itemresponse_unknownfields.json */, B3AAABD62502A40900031065 /* searchresponse_knownfields.json */, B3AAABD52502A40600031065 /* searchresponse_unknownfields.json */, + B36B8A522A4C2E930038BA1C /* test_annotation_array_position.json */, + B36B8A532A4C2E930038BA1C /* test_annotation_basic_position.json */, + B36B8A512A4C2E930038BA1C /* test_annotation_dictionary_position.json */, B32A3C85248008A2009E2C5D /* test_collection.json */, B3DF44112A408339005AF766 /* test_item_attachment.json */, - B32A3C87248008A2009E2C5D /* test_thesis_item.json */, B32A3C84248008A2009E2C5D /* test_keys.json */, B32A3C86248008A2009E2C5D /* test_search.json */, B32A3C87248008A2009E2C5D /* test_thesis_item.json */, @@ -4236,15 +4251,18 @@ B3B1EDFD2502498100D8BC1E /* collectionresponse_knownfields.json in Resources */, B3B1EDF42502456000D8BC1E /* itemresponse_unknownfields.json in Resources */, B32A3C8F248008A2009E2C5D /* translators_delete.xml in Resources */, + B36B8A552A4C2E930038BA1C /* test_annotation_array_position.json in Resources */, B3DF44122A408339005AF766 /* test_item_attachment.json in Resources */, B32A3C8E248008A2009E2C5D /* test_thesis_item.json in Resources */, B3B1EDFA2502493700D8BC1E /* Bundled in Resources */, B3AAABD72502A40900031065 /* searchresponse_unknownfields.json in Resources */, + B36B8A562A4C2E930038BA1C /* test_annotation_basic_position.json in Resources */, B32A3C8B248008A2009E2C5D /* test_keys.json in Resources */, B3B1EDFE2502498100D8BC1E /* collectionresponse_unknownfields.json in Resources */, B36EBFF5273162DD00CD788D /* bitcoin.zip in Resources */, B3AAABD82502A40900031065 /* searchresponse_knownfields.json in Resources */, B3B1EDF52502456000D8BC1E /* itemresponse_knownfields.json in Resources */, + B36B8A542A4C2E930038BA1C /* test_annotation_dictionary_position.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4785,6 +4803,7 @@ B305660F23FC051E003304F2 /* CrashUploadRequest.swift in Sources */, B39E0132283276830091CE4A /* WebViewHandler.swift in Sources */, B37AA28A28995A5800A1C643 /* ItemDetailAbstractContentView.swift in Sources */, + B3F4E4EE2A4DC39800820718 /* JSONSerialization+Utils.swift in Sources */, B3CCB8DA29B73DED0097520B /* UnlockPdfViewController.swift in Sources */, B331F9B12653F59C0099F6A6 /* RStyle.swift in Sources */, B3AAB0F8283B9915008188D6 /* LookupItemCell.swift in Sources */, @@ -5136,6 +5155,7 @@ 6144B5DB2A4ADEEB00914B3C /* SearchResponseSpec.swift in Sources */, 6144B5D62A4ADD7E00914B3C /* ItemResponseSpec.swift in Sources */, 6144B5D42A4ADCDE00914B3C /* CreatorSummaryFormatterSpec.swift in Sources */, + B3F4E4EC2A4DAA7D00820718 /* UpdatableObjectSpec.swift in Sources */, 6144B5E12A4AE95E00914B3C /* WebDavControllerSpec.swift in Sources */, 6144B5D82A4ADDC400914B3C /* ItemTitleFormatterSpec.swift in Sources */, 6144B5DF2A4AE48F00914B3C /* SyncControllerSpec.swift in Sources */, @@ -5187,6 +5207,7 @@ B305674223FC09AF003304F2 /* LinksResponse.swift in Sources */, B310091F272C016F003FC743 /* RWebDavDeletion.swift in Sources */, B386328626C5499900183062 /* TranslatorsAndStylesController.swift in Sources */, + B3F4E4EF2A4DC4E300820718 /* JSONSerialization+Utils.swift in Sources */, B3F0FDC82888177D00949BC9 /* AttachmentDownloadOperation.swift in Sources */, B305674323FC09AF003304F2 /* LoginResponse.swift in Sources */, B305674423FC09AF003304F2 /* DeletionsResponse.swift in Sources */, diff --git a/Zotero/Controllers/AnnotationConverter.swift b/Zotero/Controllers/AnnotationConverter.swift index 329baadd2..b34eb3d84 100644 --- a/Zotero/Controllers/AnnotationConverter.swift +++ b/Zotero/Controllers/AnnotationConverter.swift @@ -63,7 +63,14 @@ struct AnnotationConverter { /// - parameter displayName: Display name of current user. /// - parameter boundingBoxConverter: Converts rects from pdf coordinate space. /// - returns: Matching Zotero annotation. - static func annotation(from annotation: PSPDFKit.Annotation, color: String, library: Library, username: String, displayName: String, boundingBoxConverter: AnnotationBoundingBoxConverter?) -> DocumentAnnotation? { + static func annotation( + from annotation: PSPDFKit.Annotation, + color: String, + library: Library, + username: String, + displayName: String, + boundingBoxConverter: AnnotationBoundingBoxConverter? + ) -> DocumentAnnotation? { guard let document = annotation.document, AnnotationsConfig.supported.contains(annotation.type) else { return nil } let key = annotation.key ?? annotation.uuid @@ -115,8 +122,22 @@ struct AnnotationConverter { return nil } - return DocumentAnnotation(key: key, type: type, page: page, pageLabel: pageLabel, rects: rects, paths: paths, lineWidth: lineWidth, author: author, isAuthor: isAuthor, color: color, - comment: comment, text: text, sortIndex: sortIndex, dateModified: date) + return DocumentAnnotation( + key: key, + type: type, + page: page, + pageLabel: pageLabel, + rects: rects, + paths: paths, + lineWidth: lineWidth, + author: author, + isAuthor: isAuthor, + color: color, + comment: comment, + text: text, + sortIndex: sortIndex, + dateModified: date + ) } static func removeNewlines(from string: String) -> String { @@ -180,17 +201,45 @@ struct AnnotationConverter { /// Converts Zotero annotations to actual document (PSPDFKit) annotations with custom flags. /// - parameter zoteroAnnotations: Annotations to convert. /// - returns: Array of PSPDFKit annotations that can be added to document. - static func annotations(from items: Results, type: Kind = .zotero, interfaceStyle: UIUserInterfaceStyle, currentUserId: Int, library: Library, displayName: String, username: String, - boundingBoxConverter: AnnotationBoundingBoxConverter) -> [PSPDFKit.Annotation] { + static func annotations( + from items: Results, + type: Kind = .zotero, + interfaceStyle: UIUserInterfaceStyle, + currentUserId: Int, + library: Library, + displayName: String, + username: String, + boundingBoxConverter: AnnotationBoundingBoxConverter + ) -> [PSPDFKit.Annotation] { return items.map({ item in - return self.annotation(from: DatabaseAnnotation(item: item), type: type, interfaceStyle: interfaceStyle, currentUserId: currentUserId, library: library, displayName: displayName, - username: username, boundingBoxConverter: boundingBoxConverter) + return self.annotation( + from: DatabaseAnnotation(item: item), + type: type, + interfaceStyle: interfaceStyle, + currentUserId: currentUserId, + library: library, + displayName: displayName, + username: username, + boundingBoxConverter: boundingBoxConverter + ) }) } - static func annotation(from zoteroAnnotation: DatabaseAnnotation, type: Kind, interfaceStyle: UIUserInterfaceStyle, currentUserId: Int, library: Library, displayName: String, username: String, - boundingBoxConverter: AnnotationBoundingBoxConverter) -> PSPDFKit.Annotation { - let (color, alpha, blendMode) = AnnotationColorGenerator.color(from: UIColor(hex: zoteroAnnotation.color), isHighlight: (zoteroAnnotation.type == .highlight), userInterfaceStyle: interfaceStyle) + static func annotation( + from zoteroAnnotation: DatabaseAnnotation, + type: Kind, + interfaceStyle: UIUserInterfaceStyle, + currentUserId: Int, + library: Library, + displayName: String, + username: String, + boundingBoxConverter: AnnotationBoundingBoxConverter + ) -> PSPDFKit.Annotation { + let (color, alpha, blendMode) = AnnotationColorGenerator.color( + from: UIColor(hex: zoteroAnnotation.color), + isHighlight: (zoteroAnnotation.type == .highlight), + userInterfaceStyle: interfaceStyle + ) let annotation: PSPDFKit.Annotation switch zoteroAnnotation.type { @@ -252,7 +301,13 @@ struct AnnotationConverter { /// Creates corresponding `HighlightAnnotation`. /// - parameter annotation: Zotero annotation. - private static func highlightAnnotation(from annotation: Annotation, type: Kind, color: UIColor, alpha: CGFloat, boundingBoxConverter: AnnotationBoundingBoxConverter) -> PSPDFKit.HighlightAnnotation { + private static func highlightAnnotation( + from annotation: Annotation, + type: Kind, + color: UIColor, + alpha: CGFloat, + boundingBoxConverter: AnnotationBoundingBoxConverter + ) -> PSPDFKit.HighlightAnnotation { let highlight: PSPDFKit.HighlightAnnotation switch type { case .export: diff --git a/Zotero/Extensions/JSONSerialization+Utils.swift b/Zotero/Extensions/JSONSerialization+Utils.swift new file mode 100644 index 000000000..7e864040d --- /dev/null +++ b/Zotero/Extensions/JSONSerialization+Utils.swift @@ -0,0 +1,35 @@ +// +// JSONSerialization+Utils.swift +// Zotero +// +// Created by Michal Rentka on 29.06.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +extension JSONSerialization { + static func dataWithRoundedDecimals(withJSONObject obj: Any, options opt: JSONSerialization.WritingOptions = []) throws -> Data { + return try JSONSerialization.data(withJSONObject: self.convertDoubleToRoundedDecimal(in: obj), options: opt) + } + + private static func convertDoubleToRoundedDecimal(in object: Any) -> Any { + if let double = object as? Double { + return Decimal(double).rounded(to: 3) + } + + if let array = object as? [Any] { + return array.map({ self.convertDoubleToRoundedDecimal(in: $0) }) + } + + if let dictionary = object as? [AnyHashable: Any] { + var newObject = dictionary + for (key, value) in dictionary { + newObject[key] = self.convertDoubleToRoundedDecimal(in: value) + } + return newObject + } + + return object + } +} diff --git a/Zotero/Models/API/ItemResponse.swift b/Zotero/Models/API/ItemResponse.swift index a4518e666..77dd9855d 100644 --- a/Zotero/Models/API/ItemResponse.swift +++ b/Zotero/Models/API/ItemResponse.swift @@ -331,13 +331,18 @@ struct ItemResponse { default: break } - var value: String + let value: String if let _value = object.value as? String { value = _value } else if let _value = object.value as? Int { value = "\(_value)" } else if let _value = object.value as? Double { value = "\(_value.rounded(to: 3))" + } else if let _value = object.value as? Bool { + value = "\(_value)" + } else if let data = try? JSONSerialization.dataWithRoundedDecimals(withJSONObject: object.value), let _value = String(data: data, encoding: .utf8) { + // If `object.value` is not a basic type (string or number) convert it to JSON and store JSON string + value = _value } else { value = "\(object.value)" } @@ -358,19 +363,6 @@ struct ItemResponse { throw SchemaError.invalidValue(value: rawType, field: FieldKeys.Item.Annotation.type, key: key) } - // `rects` and `paths` are not checked in `mandatoryFields` because rects and paths are processed separately and not stored in `RItemField` as an actual field. - switch type { - case .note, .image, .highlight: - if !hasRects { - throw SchemaError.missingField(key: key, field: FieldKeys.Item.Annotation.Position.rects, itemType: itemType) - } - - case .ink: - if !hasPaths { - throw SchemaError.missingField(key: key, field: FieldKeys.Item.Annotation.Position.paths, itemType: itemType) - } - } - let mandatoryFields = FieldKeys.Item.Annotation.fields(for: type) for field in mandatoryFields { guard let value = fields[field] else { @@ -383,16 +375,6 @@ struct ItemResponse { throw SchemaError.invalidValue(value: value, field: field.key, key: key) } - case FieldKeys.Item.Annotation.sortIndex: - // Sort index consists of 3 parts separated by "|": - // - 1. page index (5 characters) - // - 2. character offset (6 characters) - // - 3. y position from top (5 characters) - let parts = value.split(separator: "|") - if parts.count != 3 || parts[0].count != 5 || parts[1].count != 6 || parts[2].count != 5 { - throw SchemaError.invalidValue(value: value, field: field.key, key: key) - } - default: break } } diff --git a/Zotero/Models/FieldKeys.swift b/Zotero/Models/FieldKeys.swift index aea5d4b9e..004687a47 100644 --- a/Zotero/Models/FieldKeys.swift +++ b/Zotero/Models/FieldKeys.swift @@ -77,19 +77,23 @@ struct FieldKeys { static func fields(for type: AnnotationType) -> [KeyBaseKeyPair] { switch type { case .highlight: - return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), KeyBaseKeyPair(key: Annotation.color, baseKey: nil), - KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), KeyBaseKeyPair(key: Annotation.text, baseKey: nil), - KeyBaseKeyPair(key: Annotation.Position.pageIndex, baseKey: Annotation.position)] + return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), + KeyBaseKeyPair(key: Annotation.text, baseKey: nil)] case .ink: - return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), KeyBaseKeyPair(key: Annotation.color, baseKey: nil), - KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), - KeyBaseKeyPair(key: Annotation.Position.pageIndex, baseKey: Annotation.position), KeyBaseKeyPair(key: Annotation.Position.lineWidth, baseKey: Annotation.position)] + return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil)] case .note, .image: - return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), KeyBaseKeyPair(key: Annotation.color, baseKey: nil), - KeyBaseKeyPair(key: Annotation.pageLabel, baseKey: nil), KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil), - KeyBaseKeyPair(key: Annotation.Position.pageIndex, baseKey: Annotation.position)] + return [KeyBaseKeyPair(key: Annotation.type, baseKey: nil), + KeyBaseKeyPair(key: Annotation.comment, baseKey: nil), + KeyBaseKeyPair(key: Annotation.color, baseKey: nil), + KeyBaseKeyPair(key: Annotation.sortIndex, baseKey: nil)] } } } diff --git a/Zotero/Models/UpdatableObject.swift b/Zotero/Models/UpdatableObject.swift index f3702054a..51bb6268b 100644 --- a/Zotero/Models/UpdatableObject.swift +++ b/Zotero/Models/UpdatableObject.swift @@ -228,6 +228,8 @@ extension RItem: Updatable { jsonData[field.key] = value } else if let value = Double(field.value) { jsonData[field.key] = value + } else if let data = field.value.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) { + jsonData[field.key] = json } else { jsonData[field.key] = field.value } @@ -235,21 +237,21 @@ extension RItem: Updatable { switch type { case .ink: - var apiPaths: [[Decimal]] = [] + var apiPaths: [[Double]] = [] for path in self.paths.sorted(byKeyPath: "sortIndex") { - apiPaths.append(path.coordinates.sorted(byKeyPath: "sortIndex").map({ Decimal($0.value).rounded(to: 3) })) + apiPaths.append(path.coordinates.sorted(byKeyPath: "sortIndex").map({ $0.value })) } jsonData[FieldKeys.Item.Annotation.Position.paths] = apiPaths case .highlight, .image, .note: - var rectArray: [[Decimal]] = [] + var rectArray: [[Double]] = [] self.rects.forEach { rRect in - rectArray.append([Decimal(rRect.minX).rounded(to: 3), Decimal(rRect.minY).rounded(to: 3), Decimal(rRect.maxX).rounded(to: 3), Decimal(rRect.maxY).rounded(to: 3)]) + rectArray.append([rRect.minX, rRect.minY, rRect.maxX, rRect.maxY]) } jsonData[FieldKeys.Item.Annotation.Position.rects] = rectArray } - return (try? JSONSerialization.data(withJSONObject: jsonData, options: [])).flatMap({ String(data: $0, encoding: .utf8) }) ?? "" + return (try? JSONSerialization.dataWithRoundedDecimals(withJSONObject: jsonData)).flatMap({ String(data: $0, encoding: .utf8) }) ?? "" } func deleteChanges(uuids: [String], database: Realm) { diff --git a/Zotero/Scenes/Detail/PDF/Models/DatabaseAnnotation.swift b/Zotero/Scenes/Detail/PDF/Models/DatabaseAnnotation.swift index f246c194e..0c4cf06bd 100644 --- a/Zotero/Scenes/Detail/PDF/Models/DatabaseAnnotation.swift +++ b/Zotero/Scenes/Detail/PDF/Models/DatabaseAnnotation.swift @@ -13,7 +13,7 @@ import PSPDFKit import RxSwift struct DatabaseAnnotation { - private let item: RItem + let item: RItem var key: String { return self.item.key diff --git a/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift b/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift index 1977043bf..bb64e357d 100644 --- a/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift +++ b/Zotero/Scenes/Detail/PDF/ViewModels/PDFReaderActionHandler.swift @@ -1560,6 +1560,7 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi private func createSortedKeys(fromDatabaseAnnotations databaseAnnotations: Results, documentAnnotations: [String: DocumentAnnotation]) -> [PDFReaderState.AnnotationKey] { var keys: [(PDFReaderState.AnnotationKey, String)] = [] for item in databaseAnnotations { + guard self.validate(databaseAnnotation: DatabaseAnnotation(item: item)) else { continue } keys.append((PDFReaderState.AnnotationKey(key: item.key, type: .database), item.annotationSortIndex)) } for annotation in documentAnnotations.values { @@ -1572,6 +1573,39 @@ final class PDFReaderActionHandler: ViewModelActionHandler, BackgroundDbProcessi return keys.map({ $0.0 }) } + private func validate(databaseAnnotation: DatabaseAnnotation) -> Bool { + if databaseAnnotation._page == nil { + return false + } + + switch databaseAnnotation.type { + case .ink: + if databaseAnnotation.item.paths.isEmpty { + DDLogInfo("PDFReaderActionHandler: ink annotation \(databaseAnnotation.key) missing paths") + return false + } + + case .highlight, .image, .note: + if databaseAnnotation.item.rects.isEmpty { + DDLogInfo("PDFReaderActionHandler: \(databaseAnnotation.type) annotation \(databaseAnnotation.key) missing rects") + return false + } + } + + // Sort index consists of 3 parts separated by "|": + // - 1. page index (5 characters) + // - 2. character offset (6 characters) + // - 3. y position from top (5 characters) + let sortIndex = databaseAnnotation.sortIndex + let parts = sortIndex.split(separator: "|") + if parts.count != 3 || parts[0].count != 5 || parts[1].count != 6 || parts[2].count != 5 { + DDLogInfo("PDFReaderActionHandler: invalid sort index (\(sortIndex)) for \(databaseAnnotation.key)") + return false + } + + return true + } + private func loadAnnotationsAndPage(for key: String, library: Library) -> Result<(Results, Int), Error> { do { var results: Results! diff --git a/ZoteroTests/ItemResponseSpec.swift b/ZoteroTests/ItemResponseSpec.swift index b8f275872..d7eddbab9 100644 --- a/ZoteroTests/ItemResponseSpec.swift +++ b/ZoteroTests/ItemResponseSpec.swift @@ -55,6 +55,67 @@ final class ItemResponseSpec: QuickSpec { }) } } + + context("with unknown basic position fields") { + beforeEach { + resourceName = "test_annotation_basic_position" + } + + it("preserves fields") { + do { + let response = try ItemResponse(response: jsonData, schemaController: TestControllers.schemaController) + expect(response.fields[KeyBaseKeyPair(key: "type", baseKey: FieldKeys.Item.Annotation.position)]).to(equal("FragmentSelector")) + expect(response.fields[KeyBaseKeyPair(key: "conformsTo", baseKey: FieldKeys.Item.Annotation.position)]).to(equal("http://www.idpf.org/epub/linking/cfi/epub-cfi.html")) + expect(response.fields[KeyBaseKeyPair(key: "value", baseKey: FieldKeys.Item.Annotation.position)]).to(equal("epubcfi(/6/102!/4/2[chapter-45]/26,/1:0,/3:254)")) + } catch let error { + fail("\(error)") + } + } + } + + context("with unknown array position fields") { + beforeEach { + resourceName = "test_annotation_array_position" + } + + it("preserves fields") { + do { + let response = try ItemResponse(response: jsonData, schemaController: TestControllers.schemaController) + let nextPageRects = response.fields[KeyBaseKeyPair(key: "nextPageRects", baseKey: FieldKeys.Item.Annotation.position)] + expect(nextPageRects).to(equal("[[65.955,709.874,293.106,718.217],[54,700.077,269.333,707.904]]")) + } catch let error { + fail("\(error)") + } + } + } + + context("with unknown dictionary position fields") { + beforeEach { + resourceName = "test_annotation_dictionary_position" + } + + it("preserves fields") { + do { + let response = try ItemResponse(response: jsonData, schemaController: TestControllers.schemaController) + + guard let rawRefinedBy = response.fields[KeyBaseKeyPair(key: "refinedBy", baseKey: FieldKeys.Item.Annotation.position)] else { + fail("refinedBy missing") + return + } + + guard let refinedBy = try? JSONSerialization.jsonObject(with: rawRefinedBy.data(using: .utf8)!, options: .allowFragments) as? [String: Any] else { + fail("refinedBy invalid json \(rawRefinedBy)") + return + } + + expect(refinedBy["type"] as? String).to(equal("TextPositionSelector")) + expect(refinedBy["start"] as? Int).to(equal(1536)) + expect(refinedBy["end"] as? Int).to(equal(1705)) + } catch let error { + fail("\(error)") + } + } + } } } } diff --git a/ZoteroTests/JSONs/test_annotation_array_position.json b/ZoteroTests/JSONs/test_annotation_array_position.json new file mode 100644 index 000000000..3ce0b4bc1 --- /dev/null +++ b/ZoteroTests/JSONs/test_annotation_array_position.json @@ -0,0 +1,37 @@ +{ + "key": "AAAAAAAA", + "version": 182, + "library": { + "type": "user", + "id": 1234123, + "name": "someuser", + "links": { + "alternate": { + "href": "https://www.zotero.org/someuser", + "type": "text/html" + } + } + }, + "links": {}, + "meta": {}, + "data": { + "key": "AAAAAAAA", + "version": 182, + "itemType": "annotation", + "parentItem": "BBBBBBBB", + "annotationType": "highlight", + "annotationComment": "", + "annotationColor": "#ffffff", + "annotationPageLabel": "1", + "annotationText": "", + "annotationSortIndex": "00000|000000|00000", + "tags": [ + { + "tag": "High priority" + } + ], + "annotationPosition": "{\"pageIndex\": 2, \"rects\": [[317.014,80.942,556.121,88.716], [317.014,70.76,399.423,79.04]], \"nextPageRects\": [[65.955,709.874,293.106,718.217], [54,700.077,269.333,707.904]]}", + "dateAdded": "2019-02-11T19:10:55Z", + "dateModified": "2019-03-28T15:48:48Z" + } + } diff --git a/ZoteroTests/JSONs/test_annotation_basic_position.json b/ZoteroTests/JSONs/test_annotation_basic_position.json new file mode 100644 index 000000000..70c37d272 --- /dev/null +++ b/ZoteroTests/JSONs/test_annotation_basic_position.json @@ -0,0 +1,37 @@ +{ + "key": "AAAAAAAA", + "version": 182, + "library": { + "type": "user", + "id": 1234123, + "name": "someuser", + "links": { + "alternate": { + "href": "https://www.zotero.org/someuser", + "type": "text/html" + } + } + }, + "links": {}, + "meta": {}, + "data": { + "key": "AAAAAAAA", + "version": 182, + "itemType": "annotation", + "parentItem": "BBBBBBBB", + "annotationType": "highlight", + "annotationComment": "", + "annotationColor": "#ffffff", + "annotationPageLabel": "1", + "annotationText": "", + "annotationSortIndex": "00000|000000|00000", + "tags": [ + { + "tag": "High priority" + } + ], + "annotationPosition": "{\"type\": \"FragmentSelector\", \"conformsTo\": \"http://www.idpf.org/epub/linking/cfi/epub-cfi.html\", \"value\": \"epubcfi(/6/102!/4/2[chapter-45]/26,/1:0,/3:254)\"}", + "dateAdded": "2019-02-11T19:10:55Z", + "dateModified": "2019-03-28T15:48:48Z" + } + } diff --git a/ZoteroTests/JSONs/test_annotation_dictionary_position.json b/ZoteroTests/JSONs/test_annotation_dictionary_position.json new file mode 100644 index 000000000..f82ed098e --- /dev/null +++ b/ZoteroTests/JSONs/test_annotation_dictionary_position.json @@ -0,0 +1,37 @@ +{ + "key": "AAAAAAAA", + "version": 182, + "library": { + "type": "user", + "id": 1234123, + "name": "someuser", + "links": { + "alternate": { + "href": "https://www.zotero.org/someuser", + "type": "text/html" + } + } + }, + "links": {}, + "meta": {}, + "data": { + "key": "AAAAAAAA", + "version": 182, + "itemType": "annotation", + "parentItem": "BBBBBBBB", + "annotationType": "highlight", + "annotationComment": "", + "annotationColor": "#ffffff", + "annotationPageLabel": "1", + "annotationText": "", + "annotationSortIndex": "00000|000000|00000", + "tags": [ + { + "tag": "High priority" + } + ], + "annotationPosition": "{\"type\": \"CssSelector\", \"value\": \"#content > div > div:first-child > div:nth-child(3)\", \"refinedBy\": {\"type\": \"TextPositionSelector\", \"start\": 1536, \"end\": 1705}}", + "dateAdded": "2019-02-11T19:10:55Z", + "dateModified": "2019-03-28T15:48:48Z" + } + } diff --git a/ZoteroTests/UpdatableObjectSpec.swift b/ZoteroTests/UpdatableObjectSpec.swift new file mode 100644 index 000000000..98c788a18 --- /dev/null +++ b/ZoteroTests/UpdatableObjectSpec.swift @@ -0,0 +1,119 @@ +// +// UpdatableObjectSpec.swift +// ZoteroTests +// +// Created by Michal Rentka on 29.06.2023. +// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved. +// + +import Foundation + +@testable import Zotero + +import RealmSwift +import Nimble +import Quick + +final class UpdatableObjectSpec: QuickSpec { + override class func spec() { + describe("an item object") { + var realm: Realm! + + beforeSuite { + let config = Realm.Configuration(inMemoryIdentifier: "TestsRealmConfig") + realm = try! Realm(configuration: config) + } + + beforeEach { + try! realm.write { + realm.deleteAll() + } + } + + context("with json position fields") { + it("creates upload parameters properly") { + let key = "AAAAAAAA" + + try! realm.write { + let item = RItem() + item.key = key + item.rawType = ItemTypes.annotation + item.dateAdded = Date() + item.dateModified = item.dateAdded + item.annotationSortIndex = "12312|1234|12312" + realm.add(item) + + let changes: RItemChanges = [.type, .fields] + item.changes.append(RObjectChange.create(changes: changes)) + + let typeField = RItemField() + typeField.key = FieldKeys.Item.Annotation.type + typeField.value = "highlight" + item.fields.append(typeField) + + let sortField = RItemField() + sortField.key = FieldKeys.Item.Annotation.sortIndex + sortField.value = item.annotationSortIndex + sortField.changed = true + item.fields.append(sortField) + + let textField = RItemField() + textField.key = FieldKeys.Item.Annotation.text + textField.value = "Some text" + textField.changed = true + item.fields.append(textField) + + let positionTypeField = RItemField() + positionTypeField.key = "type" + positionTypeField.value = "CssSelector" + positionTypeField.baseKey = FieldKeys.Item.Annotation.position + positionTypeField.changed = true + item.fields.append(positionTypeField) + + let positionValueField = RItemField() + positionValueField.key = "value" + positionValueField.value = "#content > div > div:first-child > div:nth-child(3)" + positionValueField.baseKey = FieldKeys.Item.Annotation.position + item.fields.append(positionValueField) + + let positionRefinedByField = RItemField() + positionRefinedByField.key = "refinedBy" + positionRefinedByField.value = "{\"type\": \"TextPositionSelector\",\"start\": 1536, \"end\": 1705}" + positionRefinedByField.baseKey = FieldKeys.Item.Annotation.position + item.fields.append(positionRefinedByField) + } + + let item = realm.objects(RItem.self).first! + + guard let parameters = item.updateParameters else { + fail("Parameters are nil") + return + } + + expect(parameters["key"] as? String).to(equal(key)) + expect(parameters["itemType"] as? String).to(equal(ItemTypes.annotation)) + expect(parameters[FieldKeys.Item.Annotation.sortIndex] as? String).to(equal(item.annotationSortIndex)) + expect(parameters[FieldKeys.Item.Annotation.text] as? String).to(equal("Some text")) + + guard let rawPosition = parameters["annotationPosition"] as? String, + let position = try? JSONSerialization.jsonObject(with: rawPosition.data(using: .utf8)!) as? [String: Any] else { + fail("position missing") + return + } + + expect(position["type"] as? String).to(equal("CssSelector")) + expect(position["value"] as? String).to(equal("#content > div > div:first-child > div:nth-child(3)")) + + guard let refinedBy = position["refinedBy"] as? [String: Any] else { + fail("refinedBy missing") + return + } + + expect(refinedBy["type"] as? String).to(equal("TextPositionSelector")) + expect(refinedBy["start"] as? Int).to(equal(1536)) + expect(refinedBy["end"] as? Int).to(equal(1705)) + } + } + } + } +}