From 56c04a35bfa78ffdd6fbeec6b53ec16a9143eef0 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 21:14:12 +0800 Subject: [PATCH] UI for recommendations --- .../App/Views/WebReader/RecommendToView.swift | 33 +- .../Views/WebReader/WebReaderContainer.swift | 14 +- .../CoreDataModel.xcdatamodel/contents | 7 + .../Models/DataModels/Recommendation.swift | 21 + .../Services/DataService/ContentLoading.swift | 3 +- .../Services/DataService/GQLSchema.swift | 398 ++++++++++++++++++ .../DataService/Mutations/SavePage.swift | 2 + .../Queries/ArticleContentQuery.swift | 1 + .../Queries/LinkedItemNetworkQuery.swift | 10 + .../InternalModels/InternalLinkedItem.swift | 10 + .../InternalRecommendation.swift | 36 ++ .../Views/FeedItem/HomeFeedCardView.swift | 15 + 12 files changed, 529 insertions(+), 21 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift create mode 100644 apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift index 902eae6aa..24b624a91 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -23,6 +23,7 @@ import Views do { recommendationGroups = try await dataService.recommendationGroups() } catch { + print("ERROR fetching recommendationGroups: ", error) networkError = true } @@ -68,23 +69,25 @@ struct RecommendToView: View { var body: some View { VStack { List { - ForEach(viewModel.recommendationGroups) { group in - HStack { - Text(group.name) + Section("Select groups to recommend to") { + ForEach(viewModel.recommendationGroups) { group in + HStack { + Text(group.name) - Spacer() + Spacer() - if viewModel.selectedGroups.contains(group.id) { - Image(systemName: "checkmark") + if viewModel.selectedGroups.contains(group.id) { + Image(systemName: "checkmark") + } } - } - .contentShape(Rectangle()) - .onTapGesture { - let idx = viewModel.selectedGroups.firstIndex(of: group.id) - if let idx = idx { - viewModel.selectedGroups.remove(at: idx) - } else { - viewModel.selectedGroups.append(group.id) + .contentShape(Rectangle()) + .onTapGesture { + let idx = viewModel.selectedGroups.firstIndex(of: group.id) + if let idx = idx { + viewModel.selectedGroups.remove(at: idx) + } else { + viewModel.selectedGroups.append(group.id) + } } } } @@ -101,7 +104,7 @@ struct RecommendToView: View { } ) } - .navigationBarTitle("Recommend To") + .navigationBarTitle("Recommend") .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) .navigationBarItems(leading: Button(action: { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 9da188077..36111be75 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -29,7 +29,7 @@ struct WebReaderContainerView: View { @State private var bottomBarOpacity = 0.0 @State private var errorAlertMessage: String? @State private var showErrorAlertMessage = false - @State private var displayRecommendSheet = false + @State private var showRecommendSheet = false @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -151,8 +151,8 @@ struct WebReaderContainerView: View { }).frame(width: 48, height: 48) Divider().opacity(0.8) - Button(action: share, label: { - Image(systemName: "square.and.arrow.up") + Button(action: recommend, label: { + Image(systemName: "sparkles") }).frame(width: 48, height: 48) // TODO: We don't have a single note function yet @@ -223,7 +223,7 @@ struct WebReaderContainerView: View { Button( action: { // dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0) - displayRecommendSheet = true + showRecommendSheet = true }, label: { Label("Recommend", systemImage: "sparkles") } ) @@ -359,7 +359,7 @@ struct WebReaderContainerView: View { showErrorAlertMessage = false }) } - .formSheet(isPresented: $displayRecommendSheet) { + .formSheet(isPresented: $showRecommendSheet) { NavigationView { RecommendToView( dataService: dataService, @@ -456,6 +456,10 @@ struct WebReaderContainerView: View { Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox") } + func recommend() { + showRecommendSheet = true + } + func share() { shareActionID = UUID() } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 4b026f83e..0e08359d7 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -55,6 +55,7 @@ + @@ -91,6 +92,12 @@ + + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift new file mode 100644 index 000000000..279a74268 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift @@ -0,0 +1,21 @@ +import CoreData +import Foundation + +public extension Recommendation { + var unwrappedID: String { id ?? "" } + + static func lookup(byID recommendationID: String, inContext context: NSManagedObjectContext) -> Recommendation? { + let fetchRequest: NSFetchRequest = Recommendation.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", recommendationID + ) + + var recommendation: Recommendation? + + context.performAndWait { + recommendation = (try? context.fetch(fetchRequest))?.first + } + + return recommendation + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift index 4de282df4..b1b620be8 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift @@ -48,6 +48,7 @@ extension DataService { do { objectID = try await persistArticleContent(articleProps: fetchResult) } catch { + print("caught article content error: ", error) var message = "unknown error" let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error") if case let BasicError.message(messageText) = basicError { @@ -230,7 +231,7 @@ extension DataService { } catch { // We don't propogate these errors, we just let it pass through so // the user can attempt to fetch content again. - print("Error syncUnsyncedArticleContent") + print("Error syncUnsyncedArticleContent", error) } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index c5554e066..33c18d55a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -684,6 +684,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] + let recommendedBy: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let savedByViewer: [String: Bool] let shareInfo: [String: Objects.LinkShareInfo] @@ -806,6 +807,10 @@ extension Objects.Article: Decodable { if let value = try container.decode(Double?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommendedBy": + if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -898,6 +903,7 @@ extension Objects.Article: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] + recommendedBy = map["recommendedBy"] savedAt = map["savedAt"] savedByViewer = map["savedByViewer"] shareInfo = map["shareInfo"] @@ -1276,6 +1282,22 @@ extension Fields where TypeLock == Objects.Article { } } + func recommendedBy(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommendedBy", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.recommendedBy[field.alias!]) + case .mocking: + return selection.mock() + } + } + func savedAt() throws -> DateTime { let field = GraphQLField.leaf( name: "savedAt", @@ -8036,6 +8058,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias IntegrationsSuccess = Selection } +extension Objects { + struct JoinGroupError { + let __typename: TypeName = .joinGroupError + let errorCodes: [String: [Enums.JoinGroupErrorCode]] + + enum TypeName: String, Codable { + case joinGroupError = "JoinGroupError" + } + } +} + +extension Objects.JoinGroupError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.JoinGroupErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.JoinGroupError { + func errorCodes() throws -> [Enums.JoinGroupErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias JoinGroupError = Selection +} + +extension Objects { + struct JoinGroupSuccess { + let __typename: TypeName = .joinGroupSuccess + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case joinGroupSuccess = "JoinGroupSuccess" + } + } +} + +extension Objects.JoinGroupSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "group": + if let value = try container.decode(Objects.RecommendationGroup?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + group = map["group"] + } +} + +extension Fields where TypeLock == Objects.JoinGroupSuccess { + func group(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "group", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.group[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias JoinGroupSuccess = Selection +} + extension Objects { struct Label { let __typename: TypeName = .label @@ -9517,6 +9670,7 @@ extension Objects { let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] + let joinGroup: [String: Unions.JoinGroupResult] let logOut: [String: Unions.LogOutResult] let mergeHighlight: [String: Unions.MergeHighlightResult] let moveFilter: [String: Unions.MoveFilterResult] @@ -9669,6 +9823,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.GoogleSignupResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "joinGroup": + if let value = try container.decode(Unions.JoinGroupResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "logOut": if let value = try container.decode(Unions.LogOutResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -9851,6 +10009,7 @@ extension Objects.Mutation: Decodable { generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] + joinGroup = map["joinGroup"] logOut = map["logOut"] mergeHighlight = map["mergeHighlight"] moveFilter = map["moveFilter"] @@ -10348,6 +10507,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func joinGroup(inviteCode: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "joinGroup", + arguments: [Argument(name: "inviteCode", type: "String!", value: inviteCode)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.joinGroup[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func logOut(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "logOut", @@ -13550,6 +13728,119 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecommendSuccess = Selection } +extension Objects { + struct Recommendation { + let __typename: TypeName = .recommendation + let id: [String: String] + let name: [String: String] + let recommendedAt: [String: DateTime] + + enum TypeName: String, Codable { + case recommendation = "Recommendation" + } + } +} + +extension Objects.Recommendation: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "recommendedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + name = map["name"] + recommendedAt = map["recommendedAt"] + } +} + +extension Fields where TypeLock == Objects.Recommendation { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func recommendedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "recommendedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.recommendedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Recommendation = Selection +} + extension Objects { struct RecommendationGroup { let __typename: TypeName = .recommendationGroup @@ -15180,6 +15471,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] + let recommendedBy: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let shortId: [String: String] let siteIcon: [String: String] @@ -15292,6 +15584,10 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(Double?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommendedBy": + if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -15374,6 +15670,7 @@ extension Objects.SearchItem: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] + recommendedBy = map["recommendedBy"] savedAt = map["savedAt"] shortId = map["shortId"] siteIcon = map["siteIcon"] @@ -15714,6 +16011,22 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func recommendedBy(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommendedBy", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.recommendedBy[field.alias!]) + case .mocking: + return selection.mock() + } + } + func savedAt() throws -> DateTime { let field = GraphQLField.leaf( name: "savedAt", @@ -24526,6 +24839,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias IntegrationsResult = Selection } +extension Unions { + struct JoinGroupResult { + let __typename: TypeName + let errorCodes: [String: [Enums.JoinGroupErrorCode]] + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case joinGroupError = "JoinGroupError" + case joinGroupSuccess = "JoinGroupSuccess" + } + } +} + +extension Unions.JoinGroupResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.JoinGroupErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "group": + if let value = try container.decode(Objects.RecommendationGroup?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + group = map["group"] + } +} + +extension Fields where TypeLock == Unions.JoinGroupResult { + func on(joinGroupError: Selection, joinGroupSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "JoinGroupError", selection: joinGroupError.selection), GraphQLField.fragment(type: "JoinGroupSuccess", selection: joinGroupSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .joinGroupError: + let data = Objects.JoinGroupError(errorCodes: data.errorCodes) + return try joinGroupError.decode(data: data) + case .joinGroupSuccess: + let data = Objects.JoinGroupSuccess(group: data.group) + return try joinGroupSuccess.decode(data: data) + } + case .mocking: + return joinGroupError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias JoinGroupResult = Selection +} + extension Unions { struct LabelsResult { let __typename: TypeName @@ -28541,6 +28928,17 @@ extension Enums { } } +extension Enums { + /// JoinGroupErrorCode + enum JoinGroupErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// LabelsErrorCode enum LabelsErrorCode: String, CaseIterable, Codable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift index f8b427b37..8b00c363e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift @@ -59,6 +59,7 @@ public extension DataService { } } case let .failure(error): + print("RESULT: ", result) continuation.resume(throwing: SaveArticleError.make(from: error)) } } @@ -72,6 +73,7 @@ extension SaveArticleError { case .network, .timeout: return .network case .badpayload, .badURL, .badstatus, .cancelled: + print("HTTP ERROR", httpError) return .unknown(description: httpError.localizedDescription) } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 46c4160db..bf7f5f639 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -44,6 +44,7 @@ extension DataService { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [], labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ), htmlContent: try $0.content(), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index ce5d1fb7e..6bb543079 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -224,6 +224,14 @@ extension DataService { } } +let recommendationSelection = Selection.Recommendation { + InternalRecommendation( + id: try $0.id(), + name: try $0.name(), + recommendedAt: try $0.recommendedAt().value ?? Date() + ) +} + private let libraryArticleSelection = Selection.Article { InternalLinkedItem( id: try $0.id(), @@ -249,6 +257,7 @@ private let libraryArticleSelection = Selection.Article { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [], labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } @@ -286,6 +295,7 @@ private let searchItemSelection = Selection.SearchItem { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [], labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift index 1950ef3c6..77ee4ae91 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -26,6 +26,7 @@ struct InternalLinkedItem { let contentReader: String? let originalHtml: String? let language: String? + let recommendedBy: [InternalRecommendation] var labels: [InternalLinkedItemLabel] var isPDF: Bool { @@ -72,6 +73,14 @@ struct InternalLinkedItem { linkedItem.addToLabels(label.asManagedObject(inContext: context)) } + if let existingRecommendation = linkedItem.recommendedBy { + linkedItem.removeFromRecommendedBy(existingRecommendation) + } + + for recommendation in recommendedBy { + linkedItem.addToRecommendedBy(recommendation.asManagedObject(inContext: context)) + } + return linkedItem } } @@ -133,6 +142,7 @@ extension JSONArticle { contentReader: contentReader, originalHtml: nil, language: language, + recommendedBy: [], // TODO: labels: [] ) diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift new file mode 100644 index 000000000..c706d7014 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift @@ -0,0 +1,36 @@ +import CoreData +import Foundation +import Models + +public struct InternalRecommendation: Encodable { + let id: String + let name: String + let recommendedAt: Date? + + func asManagedObject(inContext context: NSManagedObjectContext) -> Recommendation { + let existing = Recommendation.lookup(byID: id, inContext: context) + let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context) + recommendation.id = id + recommendation.name = name + recommendation.recommendedAt = recommendedAt + return recommendation + } + + public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] { + recommendations? + .compactMap { recommendation in + if let recommendation = recommendation as? Recommendation, + let id = recommendation.id, + let name = recommendation.name, + let recommendedAt = recommendation.recommendedAt + { + return InternalRecommendation( + id: id, + name: name, + recommendedAt: recommendedAt + ) + } + return nil + } ?? [] + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 42af43b98..1a7d04bd6 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -86,6 +86,21 @@ public struct FeedCard: View { } #endif } + + if let recommendedBy = item.recommendedBy, recommendedBy.count > 0 { + let str = recommendedBy.reduce("") { str, item in + if let item = item as? Recommendation, let name = item.name { + return str + name + } + return str + } + HStack { + Text("Recommended in \(str)") + .font(.appCaption) + .frame(alignment: .leading) + Spacer() + } + } } .padding(.top, 0) .padding(.bottom, 8)