From bdbad81fa94625d9d61071757deb3c083a590f11 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 15:08:47 +0800 Subject: [PATCH 01/20] Add rough UI for recommendation groups --- .../App/Views/Profile/ProfileView.swift | 6 + .../Profile/RecommendationGroupView.swift | 74 + .../Profile/RecommendationGroupsView.swift | 145 ++ .../CoreDataModel.xcdatamodel/contents | 7 + .../Services/DataService/GQLSchema.swift | 2189 +++++++++++++++++ .../Mutations/CreateRecommendationGroup.swift | 53 + .../Queries/RecommendationGroupsQuery.swift | 52 + .../RecommendationGroupSelection.swift | 10 + .../InternalRecommendationGroup.swift | 65 + 9 files changed, 2601 insertions(+) create mode 100644 apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift create mode 100644 apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index e73aa03f8..10ecb95af 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -107,6 +107,12 @@ struct ProfileView: View { } #endif + Section { + NavigationLink(destination: GroupsView()) { + Text("Recommendation Groups") + } + } + Section { NavigationLink( destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift new file mode 100644 index 000000000..e4bdcba4e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -0,0 +1,74 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class RecommendationsGroupViewModel: ObservableObject { + @Published var isLoading = false + @Published var networkError = true + @Published var recommendationGroup: InternalRecommendationGroup + + init(recommendationGroup: InternalRecommendationGroup) { + self.recommendationGroup = recommendationGroup + } + + func loadGroups(dataService _: DataService) async { + isLoading = true + +// do { +// recommendationGroups = try await dataService.recommendationGroups() +// } catch { +// networkError = true +// } + + isLoading = false + } +} + +struct RecommendationGroupView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel: RecommendationsGroupViewModel + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .task { await viewModel.loadGroups(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section("Name") { + Text(viewModel.recommendationGroup.name) + } + + Section("Invite Link") { + Button(action: { + #if os(iOS) + UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl + #endif + + #if os(macOS) + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.writeObjects([viewModel.recommendationGroup.inviteUrl as NSString]) + #endif + + Snackbar.show(message: "Invite link copied") + }, label: { + Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))") + }) + } + } + .navigationTitle(viewModel.recommendationGroup.name) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift new file mode 100644 index 000000000..570072b41 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift @@ -0,0 +1,145 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class RecommendationsGroupsViewModel: ObservableObject { + @Published var isLoading = false + @Published var isCreating = false + @Published var networkError = true + @Published var recommendationGroups = [InternalRecommendationGroup]() + + @Published var showCreateSheet = false + + @Published var showCreateError = false + @Published var createGroupError: String? + + func loadGroups(dataService: DataService) async { + isLoading = true + + do { + recommendationGroups = try await dataService.recommendationGroups() + } catch { + networkError = true + } + + isLoading = false + } + + func createGroup(dataService: DataService, name: String) async { + isCreating = true + + if let group = try? await dataService.createRecommendationGroup(name: name) { + print("CREATED GROUP: ", group) + await loadGroups(dataService: dataService) + showCreateSheet = false + } else { + createGroupError = "Error creating group" + showCreateError = true + } + + isCreating = false + } +} + +struct CreateRecommendationGroupView: View { + @State var name = "" + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = RecommendationsGroupsViewModel() + + var nextButton: some View { + if viewModel.isCreating { + return AnyView(ProgressView()) + } else { + return AnyView(Button(action: { + Task { + await viewModel.createGroup(dataService: dataService, name: self.name) + } + }, label: { + Text("Next") + }) + .disabled(name.isEmpty) + ) + } + } + + var body: some View { + NavigationView { + Form { + TextField("Name", text: $name, prompt: Text("Group Name")) + } + .alert(isPresented: $viewModel.showCreateError) { + Alert( + title: Text(viewModel.createGroupError ?? "Error creating group"), + dismissButton: .cancel(Text("Ok")) { + viewModel.createGroupError = nil + viewModel.showCreateError = false + } + ) + } + } + .navigationViewStyle(.stack) + .navigationTitle("Create Group") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: + Button(action: { + viewModel.showCreateSheet = false + }, label: { Text("Cancel") }), + trailing: nextButton) + } +} + +struct GroupsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = RecommendationsGroupsViewModel() + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .sheet(isPresented: $viewModel.showCreateSheet) { + NavigationView { + CreateRecommendationGroupView(viewModel: self.viewModel) + } + } + .task { await viewModel.loadGroups(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section { + Button( + action: { viewModel.showCreateSheet = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new group") + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + Section(header: Text("Your recommendation groups")) { + ForEach(viewModel.recommendationGroups) { recommendationGroup in + NavigationLink( + destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)) + ) { + Text(recommendationGroup.name) + } + } + } + } + .navigationTitle("Recommendation Groups") + } +} 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 ace07414f..4b026f83e 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -91,6 +91,13 @@ + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 2b4d961e2..c5554e066 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -2596,6 +2596,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateArticleSuccess = Selection } +extension Objects { + struct CreateGroupError { + let __typename: TypeName = .createGroupError + let errorCodes: [String: [Enums.CreateGroupErrorCode]] + + enum TypeName: String, Codable { + case createGroupError = "CreateGroupError" + } + } +} + +extension Objects.CreateGroupError: 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.CreateGroupErrorCode]?.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.CreateGroupError { + func errorCodes() throws -> [Enums.CreateGroupErrorCode] { + 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 CreateGroupError = Selection +} + +extension Objects { + struct CreateGroupSuccess { + let __typename: TypeName = .createGroupSuccess + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case createGroupSuccess = "CreateGroupSuccess" + } + } +} + +extension Objects.CreateGroupSuccess: 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.CreateGroupSuccess { + 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 CreateGroupSuccess = Selection +} + extension Objects { struct CreateHighlightError { let __typename: TypeName = .createHighlightError @@ -3512,6 +3643,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountSuccess = Selection } +extension Objects { + struct DeleteFilterError { + let __typename: TypeName = .deleteFilterError + let errorCodes: [String: [Enums.DeleteFilterErrorCode]] + + enum TypeName: String, Codable { + case deleteFilterError = "DeleteFilterError" + } + } +} + +extension Objects.DeleteFilterError: 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.DeleteFilterErrorCode]?.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.DeleteFilterError { + func errorCodes() throws -> [Enums.DeleteFilterErrorCode] { + 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 DeleteFilterError = Selection +} + +extension Objects { + struct DeleteFilterSuccess { + let __typename: TypeName = .deleteFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case deleteFilterSuccess = "DeleteFilterSuccess" + } + } +} + +extension Objects.DeleteFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.DeleteFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteFilterSuccess = Selection +} + extension Objects { struct DeleteHighlightError { let __typename: TypeName = .deleteHighlightError @@ -5654,6 +5916,343 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesSuccess = Selection } +extension Objects { + struct Filter { + let __typename: TypeName = .filter + let createdAt: [String: DateTime] + let description: [String: String] + let filter: [String: String] + let id: [String: String] + let name: [String: String] + let position: [String: Int] + let updatedAt: [String: DateTime] + + enum TypeName: String, Codable { + case filter = "Filter" + } + } +} + +extension Objects.Filter: 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 "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + 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 "position": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedAt": + 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)." + ) + ) + } + } + + createdAt = map["createdAt"] + description = map["description"] + filter = map["filter"] + id = map["id"] + name = map["name"] + position = map["position"] + updatedAt = map["updatedAt"] + } +} + +extension Fields where TypeLock == Objects.Filter { + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func filter() throws -> String { + let field = GraphQLField.leaf( + name: "filter", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + 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 position() throws -> Int { + let field = GraphQLField.leaf( + name: "position", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.position[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Int.mockValue + } + } + + func updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Filter = Selection +} + +extension Objects { + struct FiltersError { + let __typename: TypeName = .filtersError + let errorCodes: [String: [Enums.FiltersErrorCode]] + + enum TypeName: String, Codable { + case filtersError = "FiltersError" + } + } +} + +extension Objects.FiltersError: 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.FiltersErrorCode]?.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.FiltersError { + func errorCodes() throws -> [Enums.FiltersErrorCode] { + 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 FiltersError = Selection +} + +extension Objects { + struct FiltersSuccess { + let __typename: TypeName = .filtersSuccess + let filters: [String: [Objects.Filter]] + + enum TypeName: String, Codable { + case filtersSuccess = "FiltersSuccess" + } + } +} + +extension Objects.FiltersSuccess: 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 "filters": + if let value = try container.decode([Objects.Filter]?.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)." + ) + ) + } + } + + filters = map["filters"] + } +} + +extension Fields where TypeLock == Objects.FiltersSuccess { + func filters(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filters", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filters[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FiltersSuccess = Selection +} + extension Objects { struct GenerateApiKeyError { let __typename: TypeName = .generateApiKeyError @@ -6306,6 +6905,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias GoogleSignupSuccess = Selection } +extension Objects { + struct GroupsError { + let __typename: TypeName = .groupsError + let errorCodes: [String: [Enums.GroupsErrorCode]] + + enum TypeName: String, Codable { + case groupsError = "GroupsError" + } + } +} + +extension Objects.GroupsError: 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.GroupsErrorCode]?.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.GroupsError { + func errorCodes() throws -> [Enums.GroupsErrorCode] { + 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 GroupsError = Selection +} + +extension Objects { + struct GroupsSuccess { + let __typename: TypeName = .groupsSuccess + let groups: [String: [Objects.RecommendationGroup]] + + enum TypeName: String, Codable { + case groupsSuccess = "GroupsSuccess" + } + } +} + +extension Objects.GroupsSuccess: 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 "groups": + 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)." + ) + ) + } + } + + groups = map["groups"] + } +} + +extension Fields where TypeLock == Objects.GroupsSuccess { + func groups(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "groups", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.groups[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GroupsSuccess = Selection +} + extension Objects { struct Highlight { let __typename: TypeName = .highlight @@ -8498,6 +9228,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias MergeHighlightSuccess = Selection } +extension Objects { + struct MoveFilterError { + let __typename: TypeName = .moveFilterError + let errorCodes: [String: [Enums.MoveFilterErrorCode]] + + enum TypeName: String, Codable { + case moveFilterError = "MoveFilterError" + } + } +} + +extension Objects.MoveFilterError: 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.MoveFilterErrorCode]?.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.MoveFilterError { + func errorCodes() throws -> [Enums.MoveFilterErrorCode] { + 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 MoveFilterError = Selection +} + +extension Objects { + struct MoveFilterSuccess { + let __typename: TypeName = .moveFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case moveFilterSuccess = "MoveFilterSuccess" + } + } +} + +extension Objects.MoveFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.MoveFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveFilterSuccess = Selection +} + extension Objects { struct MoveLabelError { let __typename: TypeName = .moveLabelError @@ -8635,6 +9496,7 @@ extension Objects { let addPopularRead: [String: Unions.AddPopularReadResult] let createArticle: [String: Unions.CreateArticleResult] let createArticleSavingRequest: [String: Unions.CreateArticleSavingRequestResult] + let createGroup: [String: Unions.CreateGroupResult] let createHighlight: [String: Unions.CreateHighlightResult] let createHighlightReply: [String: Unions.CreateHighlightReplyResult] let createLabel: [String: Unions.CreateLabelResult] @@ -8642,6 +9504,7 @@ extension Objects { let createReaction: [String: Unions.CreateReactionResult] let createReminder: [String: Unions.CreateReminderResult] let deleteAccount: [String: Unions.DeleteAccountResult] + let deleteFilter: [String: Unions.DeleteFilterResult] let deleteHighlight: [String: Unions.DeleteHighlightResult] let deleteHighlightReply: [String: Unions.DeleteHighlightReplyResult] let deleteIntegration: [String: Unions.DeleteIntegrationResult] @@ -8656,12 +9519,15 @@ extension Objects { let googleSignup: [String: Unions.GoogleSignupResult] let logOut: [String: Unions.LogOutResult] let mergeHighlight: [String: Unions.MergeHighlightResult] + let moveFilter: [String: Unions.MoveFilterResult] let moveLabel: [String: Unions.MoveLabelResult] let optInFeature: [String: Unions.OptInFeatureResult] + let recommend: [String: Unions.RecommendResult] let reportItem: [String: Objects.ReportItemResult] let revokeApiKey: [String: Unions.RevokeApiKeyResult] let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult] let saveFile: [String: Unions.SaveResult] + let saveFilter: [String: Unions.SaveFilterResult] let savePage: [String: Unions.SaveResult] let saveUrl: [String: Unions.SaveResult] let setBookmarkArticle: [String: Unions.SetBookmarkArticleResult] @@ -8719,6 +9585,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.CreateArticleSavingRequestResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "createGroup": + if let value = try container.decode(Unions.CreateGroupResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "createHighlight": if let value = try container.decode(Unions.CreateHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8747,6 +9617,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "deleteFilter": + if let value = try container.decode(Unions.DeleteFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "deleteHighlight": if let value = try container.decode(Unions.DeleteHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8803,6 +9677,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.MergeHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "moveFilter": + if let value = try container.decode(Unions.MoveFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "moveLabel": if let value = try container.decode(Unions.MoveLabelResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8811,6 +9689,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.OptInFeatureResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommend": + if let value = try container.decode(Unions.RecommendResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "reportItem": if let value = try container.decode(Objects.ReportItemResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8827,6 +9709,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "saveFilter": + if let value = try container.decode(Unions.SaveFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savePage": if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8944,6 +9830,7 @@ extension Objects.Mutation: Decodable { addPopularRead = map["addPopularRead"] createArticle = map["createArticle"] createArticleSavingRequest = map["createArticleSavingRequest"] + createGroup = map["createGroup"] createHighlight = map["createHighlight"] createHighlightReply = map["createHighlightReply"] createLabel = map["createLabel"] @@ -8951,6 +9838,7 @@ extension Objects.Mutation: Decodable { createReaction = map["createReaction"] createReminder = map["createReminder"] deleteAccount = map["deleteAccount"] + deleteFilter = map["deleteFilter"] deleteHighlight = map["deleteHighlight"] deleteHighlightReply = map["deleteHighlightReply"] deleteIntegration = map["deleteIntegration"] @@ -8965,12 +9853,15 @@ extension Objects.Mutation: Decodable { googleSignup = map["googleSignup"] logOut = map["logOut"] mergeHighlight = map["mergeHighlight"] + moveFilter = map["moveFilter"] moveLabel = map["moveLabel"] optInFeature = map["optInFeature"] + recommend = map["recommend"] reportItem = map["reportItem"] revokeApiKey = map["revokeApiKey"] saveArticleReadingProgress = map["saveArticleReadingProgress"] saveFile = map["saveFile"] + saveFilter = map["saveFilter"] savePage = map["savePage"] saveUrl = map["saveUrl"] setBookmarkArticle = map["setBookmarkArticle"] @@ -9058,6 +9949,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func createGroup(input: InputObjects.CreateGroupInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "createGroup", + arguments: [Argument(name: "input", type: "CreateGroupInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createGroup[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func createHighlight(input: InputObjects.CreateHighlightInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "createHighlight", @@ -9191,6 +10101,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func deleteFilter(id: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteFilter", + arguments: [Argument(name: "id", type: "ID!", value: id)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteHighlight(highlightId: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteHighlight", @@ -9457,6 +10386,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func moveFilter(input: InputObjects.MoveFilterInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "moveFilter", + arguments: [Argument(name: "input", type: "MoveFilterInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.moveFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func moveLabel(input: InputObjects.MoveLabelInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "moveLabel", @@ -9495,6 +10443,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func recommend(input: InputObjects.RecommendInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommend", + arguments: [Argument(name: "input", type: "RecommendInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.recommend[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func reportItem(input: InputObjects.ReportItemInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "reportItem", @@ -9571,6 +10538,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func saveFilter(input: InputObjects.SaveFilterInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "saveFilter", + arguments: [Argument(name: "input", type: "SaveFilterInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func savePage(input: InputObjects.SavePageInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "savePage", @@ -11126,9 +12112,11 @@ extension Objects { let articles: [String: Unions.ArticlesResult] let deviceTokens: [String: Unions.DeviceTokensResult] let feedArticles: [String: Unions.FeedArticlesResult] + let filters: [String: Unions.FiltersResult] let getFollowers: [String: Unions.GetFollowersResult] let getFollowing: [String: Unions.GetFollowingResult] let getUserPersonalization: [String: Unions.GetUserPersonalizationResult] + let groups: [String: Unions.GroupsResult] let hello: [String: String] let integrations: [String: Unions.IntegrationsResult] let labels: [String: Unions.LabelsResult] @@ -11191,6 +12179,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.FeedArticlesResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "filters": + if let value = try container.decode(Unions.FiltersResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "getFollowers": if let value = try container.decode(Unions.GetFollowersResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11203,6 +12195,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.GetUserPersonalizationResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "groups": + if let value = try container.decode(Unions.GroupsResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "hello": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11295,9 +12291,11 @@ extension Objects.Query: Decodable { articles = map["articles"] deviceTokens = map["deviceTokens"] feedArticles = map["feedArticles"] + filters = map["filters"] getFollowers = map["getFollowers"] getFollowing = map["getFollowing"] getUserPersonalization = map["getUserPersonalization"] + groups = map["groups"] hello = map["hello"] integrations = map["integrations"] labels = map["labels"] @@ -11435,6 +12433,25 @@ extension Fields where TypeLock == Objects.Query { } } + func filters(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filters", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filters[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func getFollowers(userId: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "getFollowers", @@ -11492,6 +12509,25 @@ extension Fields where TypeLock == Objects.Query { } } + func groups(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "groups", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.groups[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func hello() throws -> String? { let field = GraphQLField.leaf( name: "hello", @@ -12384,6 +13420,347 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecentSearchesSuccess = Selection } +extension Objects { + struct RecommendError { + let __typename: TypeName = .recommendError + let errorCodes: [String: [Enums.RecommendErrorCode]] + + enum TypeName: String, Codable { + case recommendError = "RecommendError" + } + } +} + +extension Objects.RecommendError: 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.RecommendErrorCode]?.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.RecommendError { + func errorCodes() throws -> [Enums.RecommendErrorCode] { + 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 RecommendError = Selection +} + +extension Objects { + struct RecommendSuccess { + let __typename: TypeName = .recommendSuccess + let taskNames: [String: [String]] + + enum TypeName: String, Codable { + case recommendSuccess = "RecommendSuccess" + } + } +} + +extension Objects.RecommendSuccess: 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 "taskNames": + if let value = try container.decode([String]?.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)." + ) + ) + } + } + + taskNames = map["taskNames"] + } +} + +extension Fields where TypeLock == Objects.RecommendSuccess { + func taskNames() throws -> [String] { + let field = GraphQLField.leaf( + name: "taskNames", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.taskNames[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendSuccess = Selection +} + +extension Objects { + struct RecommendationGroup { + let __typename: TypeName = .recommendationGroup + let admins: [String: [Objects.User]] + let createdAt: [String: DateTime] + let id: [String: String] + let inviteUrl: [String: String] + let members: [String: [Objects.User]] + let name: [String: String] + let updatedAt: [String: DateTime] + + enum TypeName: String, Codable { + case recommendationGroup = "RecommendationGroup" + } + } +} + +extension Objects.RecommendationGroup: 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 "admins": + if let value = try container.decode([Objects.User]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "inviteUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "members": + if let value = try container.decode([Objects.User]?.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 "updatedAt": + 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)." + ) + ) + } + } + + admins = map["admins"] + createdAt = map["createdAt"] + id = map["id"] + inviteUrl = map["inviteUrl"] + members = map["members"] + name = map["name"] + updatedAt = map["updatedAt"] + } +} + +extension Fields where TypeLock == Objects.RecommendationGroup { + func admins(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "admins", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.admins[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + 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 inviteUrl() throws -> String { + let field = GraphQLField.leaf( + name: "inviteUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.inviteUrl[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func members(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "members", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.members[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + 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 updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendationGroup = Selection +} + extension Objects { struct Reminder { let __typename: TypeName = .reminder @@ -13495,6 +14872,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveError = Selection } +extension Objects { + struct SaveFilterError { + let __typename: TypeName = .saveFilterError + let errorCodes: [String: [Enums.SaveFilterErrorCode]] + + enum TypeName: String, Codable { + case saveFilterError = "SaveFilterError" + } + } +} + +extension Objects.SaveFilterError: 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.SaveFilterErrorCode]?.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.SaveFilterError { + func errorCodes() throws -> [Enums.SaveFilterErrorCode] { + 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 SaveFilterError = Selection +} + +extension Objects { + struct SaveFilterSuccess { + let __typename: TypeName = .saveFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case saveFilterSuccess = "SaveFilterSuccess" + } + } +} + +extension Objects.SaveFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.SaveFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveFilterSuccess = Selection +} + extension Objects { struct SaveSuccess { let __typename: TypeName = .saveSuccess @@ -20940,6 +22448,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateArticleSavingRequestResult = Selection } +extension Unions { + struct CreateGroupResult { + let __typename: TypeName + let errorCodes: [String: [Enums.CreateGroupErrorCode]] + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case createGroupError = "CreateGroupError" + case createGroupSuccess = "CreateGroupSuccess" + } + } +} + +extension Unions.CreateGroupResult: 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.CreateGroupErrorCode]?.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.CreateGroupResult { + func on(createGroupError: Selection, createGroupSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "CreateGroupError", selection: createGroupError.selection), GraphQLField.fragment(type: "CreateGroupSuccess", selection: createGroupSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .createGroupError: + let data = Objects.CreateGroupError(errorCodes: data.errorCodes) + return try createGroupError.decode(data: data) + case .createGroupSuccess: + let data = Objects.CreateGroupSuccess(group: data.group) + return try createGroupSuccess.decode(data: data) + } + case .mocking: + return createGroupError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias CreateGroupResult = Selection +} + extension Unions { struct CreateHighlightReplyResult { let __typename: TypeName @@ -21458,6 +23040,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountResult = Selection } +extension Unions { + struct DeleteFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case deleteFilterError = "DeleteFilterError" + case deleteFilterSuccess = "DeleteFilterSuccess" + } + } +} + +extension Unions.DeleteFilterResult: 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.DeleteFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.DeleteFilterResult { + func on(deleteFilterError: Selection, deleteFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteFilterError", selection: deleteFilterError.selection), GraphQLField.fragment(type: "DeleteFilterSuccess", selection: deleteFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteFilterError: + let data = Objects.DeleteFilterError(errorCodes: data.errorCodes) + return try deleteFilterError.decode(data: data) + case .deleteFilterSuccess: + let data = Objects.DeleteFilterSuccess(filter: data.filter) + return try deleteFilterSuccess.decode(data: data) + } + case .mocking: + return deleteFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteFilterResult = Selection +} + extension Unions { struct DeleteHighlightReplyResult { let __typename: TypeName @@ -22278,6 +23934,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesResult = Selection } +extension Unions { + struct FiltersResult { + let __typename: TypeName + let errorCodes: [String: [Enums.FiltersErrorCode]] + let filters: [String: [Objects.Filter]] + + enum TypeName: String, Codable { + case filtersError = "FiltersError" + case filtersSuccess = "FiltersSuccess" + } + } +} + +extension Unions.FiltersResult: 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.FiltersErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filters": + if let value = try container.decode([Objects.Filter]?.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"] + filters = map["filters"] + } +} + +extension Fields where TypeLock == Unions.FiltersResult { + func on(filtersError: Selection, filtersSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "FiltersError", selection: filtersError.selection), GraphQLField.fragment(type: "FiltersSuccess", selection: filtersSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .filtersError: + let data = Objects.FiltersError(errorCodes: data.errorCodes) + return try filtersError.decode(data: data) + case .filtersSuccess: + let data = Objects.FiltersSuccess(filters: data.filters) + return try filtersSuccess.decode(data: data) + } + case .mocking: + return filtersError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FiltersResult = Selection +} + extension Unions { struct GenerateApiKeyResult { let __typename: TypeName @@ -22648,6 +24378,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias GoogleSignupResult = Selection } +extension Unions { + struct GroupsResult { + let __typename: TypeName + let errorCodes: [String: [Enums.GroupsErrorCode]] + let groups: [String: [Objects.RecommendationGroup]] + + enum TypeName: String, Codable { + case groupsError = "GroupsError" + case groupsSuccess = "GroupsSuccess" + } + } +} + +extension Unions.GroupsResult: 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.GroupsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "groups": + 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"] + groups = map["groups"] + } +} + +extension Fields where TypeLock == Unions.GroupsResult { + func on(groupsError: Selection, groupsSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GroupsError", selection: groupsError.selection), GraphQLField.fragment(type: "GroupsSuccess", selection: groupsSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .groupsError: + let data = Objects.GroupsError(errorCodes: data.errorCodes) + return try groupsError.decode(data: data) + case .groupsSuccess: + let data = Objects.GroupsSuccess(groups: data.groups) + return try groupsSuccess.decode(data: data) + } + case .mocking: + return groupsError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GroupsResult = Selection +} + extension Unions { struct IntegrationsResult { let __typename: TypeName @@ -23024,6 +24828,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias MergeHighlightResult = Selection } +extension Unions { + struct MoveFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.MoveFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case moveFilterError = "MoveFilterError" + case moveFilterSuccess = "MoveFilterSuccess" + } + } +} + +extension Unions.MoveFilterResult: 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.MoveFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.MoveFilterResult { + func on(moveFilterError: Selection, moveFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "MoveFilterError", selection: moveFilterError.selection), GraphQLField.fragment(type: "MoveFilterSuccess", selection: moveFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .moveFilterError: + let data = Objects.MoveFilterError(errorCodes: data.errorCodes) + return try moveFilterError.decode(data: data) + case .moveFilterSuccess: + let data = Objects.MoveFilterSuccess(filter: data.filter) + return try moveFilterSuccess.decode(data: data) + } + case .mocking: + return moveFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveFilterResult = Selection +} + extension Unions { struct MoveLabelResult { let __typename: TypeName @@ -23320,6 +25198,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecentSearchesResult = Selection } +extension Unions { + struct RecommendResult { + let __typename: TypeName + let errorCodes: [String: [Enums.RecommendErrorCode]] + let taskNames: [String: [String]] + + enum TypeName: String, Codable { + case recommendError = "RecommendError" + case recommendSuccess = "RecommendSuccess" + } + } +} + +extension Unions.RecommendResult: 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.RecommendErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "taskNames": + if let value = try container.decode([String]?.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"] + taskNames = map["taskNames"] + } +} + +extension Fields where TypeLock == Unions.RecommendResult { + func on(recommendError: Selection, recommendSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "RecommendError", selection: recommendError.selection), GraphQLField.fragment(type: "RecommendSuccess", selection: recommendSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .recommendError: + let data = Objects.RecommendError(errorCodes: data.errorCodes) + return try recommendError.decode(data: data) + case .recommendSuccess: + let data = Objects.RecommendSuccess(taskNames: data.taskNames) + return try recommendSuccess.decode(data: data) + } + case .mocking: + return recommendError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendResult = Selection +} + extension Unions { struct ReminderResult { let __typename: TypeName @@ -23616,6 +25568,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressResult = Selection } +extension Unions { + struct SaveFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SaveFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case saveFilterError = "SaveFilterError" + case saveFilterSuccess = "SaveFilterSuccess" + } + } +} + +extension Unions.SaveFilterResult: 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.SaveFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.SaveFilterResult { + func on(saveFilterError: Selection, saveFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SaveFilterError", selection: saveFilterError.selection), GraphQLField.fragment(type: "SaveFilterSuccess", selection: saveFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .saveFilterError: + let data = Objects.SaveFilterError(errorCodes: data.errorCodes) + return try saveFilterError.decode(data: data) + case .saveFilterSuccess: + let data = Objects.SaveFilterSuccess(filter: data.filter) + return try saveFilterSuccess.decode(data: data) + } + case .mocking: + return saveFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveFilterResult = Selection +} + extension Unions { struct SaveResult { let __typename: TypeName @@ -26227,6 +28253,15 @@ extension Enums { } } +extension Enums { + /// CreateGroupErrorCode + enum CreateGroupErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// CreateHighlightErrorCode enum CreateHighlightErrorCode: String, CaseIterable, Codable { @@ -26314,6 +28349,17 @@ extension Enums { } } +extension Enums { + /// DeleteFilterErrorCode + enum DeleteFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// DeleteHighlightErrorCode enum DeleteHighlightErrorCode: String, CaseIterable, Codable { @@ -26429,6 +28475,15 @@ extension Enums { } } +extension Enums { + /// FiltersErrorCode + enum FiltersErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// GenerateApiKeyErrorCode enum GenerateApiKeyErrorCode: String, CaseIterable, Codable { @@ -26461,6 +28516,15 @@ extension Enums { } } +extension Enums { + /// GroupsErrorCode + enum GroupsErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// IntegrationType enum IntegrationType: String, CaseIterable, Codable { @@ -26527,6 +28591,17 @@ extension Enums { } } +extension Enums { + /// MoveFilterErrorCode + enum MoveFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// MoveLabelErrorCode enum MoveLabelErrorCode: String, CaseIterable, Codable { @@ -26601,6 +28676,17 @@ extension Enums { } } +extension Enums { + /// RecommendErrorCode + enum RecommendErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// ReminderErrorCode enum ReminderErrorCode: String, CaseIterable, Codable { @@ -26680,6 +28766,17 @@ extension Enums { } } +extension Enums { + /// SaveFilterErrorCode + enum SaveFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// SearchErrorCode enum SearchErrorCode: String, CaseIterable, Codable { @@ -27208,6 +29305,29 @@ extension InputObjects { } } +extension InputObjects { + struct CreateGroupInput: Encodable, Hashable { + var expiresInDays: OptionalArgument = .absent() + + var maxMembers: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if expiresInDays.hasValue { try container.encode(expiresInDays, forKey: .expiresInDays) } + if maxMembers.hasValue { try container.encode(maxMembers, forKey: .maxMembers) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case expiresInDays + case maxMembers + case name + } + } +} + extension InputObjects { struct CreateHighlightInput: Encodable, Hashable { var annotation: OptionalArgument = .absent() @@ -27495,6 +29615,25 @@ extension InputObjects { } } +extension InputObjects { + struct MoveFilterInput: Encodable, Hashable { + var afterFilterId: OptionalArgument = .absent() + + var filterId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if afterFilterId.hasValue { try container.encode(afterFilterId, forKey: .afterFilterId) } + try container.encode(filterId, forKey: .filterId) + } + + enum CodingKeys: String, CodingKey { + case afterFilterId + case filterId + } + } +} + extension InputObjects { struct MoveLabelInput: Encodable, Hashable { var afterLabelId: OptionalArgument = .absent() @@ -27587,6 +29726,25 @@ extension InputObjects { } } +extension InputObjects { + struct RecommendInput: Encodable, Hashable { + var groupIds: [String] + + var pageId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(groupIds, forKey: .groupIds) + try container.encode(pageId, forKey: .pageId) + } + + enum CodingKeys: String, CodingKey { + case groupIds + case pageId + } + } +} + extension InputObjects { struct ReportItemInput: Encodable, Hashable { var itemUrl: String @@ -27687,6 +29845,33 @@ extension InputObjects { } } +extension InputObjects { + struct SaveFilterInput: Encodable, Hashable { + var description: OptionalArgument = .absent() + + var filter: String + + var id: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(filter, forKey: .filter) + if id.hasValue { try container.encode(id, forKey: .id) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case description + case filter + case id + case name + } + } +} + extension InputObjects { struct SavePageInput: Encodable, Hashable { var clientRequestId: String @@ -28149,6 +30334,8 @@ extension InputObjects { var pageId: String + var savedAt: OptionalArgument = .absent() + var title: OptionalArgument = .absent() func encode(to encoder: Encoder) throws { @@ -28156,6 +30343,7 @@ extension InputObjects { if byline.hasValue { try container.encode(byline, forKey: .byline) } if description.hasValue { try container.encode(description, forKey: .description) } try container.encode(pageId, forKey: .pageId) + if savedAt.hasValue { try container.encode(savedAt, forKey: .savedAt) } if title.hasValue { try container.encode(title, forKey: .title) } } @@ -28163,6 +30351,7 @@ extension InputObjects { case byline case description case pageId + case savedAt case title } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift new file mode 100644 index 000000000..646cf45dc --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift @@ -0,0 +1,53 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createRecommendationGroup(name: String) async throws -> InternalRecommendationGroup { + enum MutationResult { + case saved(recommendationGroup: InternalRecommendationGroup) + case error(errorCode: Enums.CreateGroupErrorCode) + } + + let selection = Selection { + try $0.on( + createGroupError: .init { + .error(errorCode: try $0.errorCodes().first ?? .badRequest) + }, + createGroupSuccess: .init { + .saved(recommendationGroup: try $0.group(selection: recommendationGroupSelection)) + } + ) + } + + let input = InputObjects.CreateGroupInput(name: name) + + let mutation = Selection.Mutation { + try $0.createGroup(input: input, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { [weak self] queryResult in + guard let payload = try? queryResult.get(), let self = self else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .saved(recommendationGroup: recommendationGroup): + if [recommendationGroup].persist(context: self.backgroundContext) != nil { + continuation.resume(returning: recommendationGroup) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) + } + case let .error(errorCode: errorCode): + continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift new file mode 100644 index 000000000..a17a5c52c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift @@ -0,0 +1,52 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func recommendationGroups() async throws -> [InternalRecommendationGroup] { + enum QueryResult { + case success(result: [InternalRecommendationGroup]) + case error(error: String) + } + + let selection = Selection { + try $0.on( + groupsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + groupsSuccess: .init { + QueryResult.success(result: try $0.groups(selection: recommendationGroupSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.groups(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + let context = backgroundContext + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: result): + if result.persist(context: context) != nil { + continuation.resume(returning: result) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) + } + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Recommendation Groups fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift new file mode 100644 index 000000000..4064c1bf9 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift @@ -0,0 +1,10 @@ +import Models +import SwiftGraphQL + +let recommendationGroupSelection = Selection.RecommendationGroup { + InternalRecommendationGroup( + id: try $0.id(), + name: try $0.name(), + inviteUrl: try $0.inviteUrl() + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift new file mode 100644 index 000000000..c787cc1f8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift @@ -0,0 +1,65 @@ +// +// InternalRecommendationGroup.swift +// +// +// Created by Jackson Harper on 12/5/22. +// + +import CoreData +import Foundation +import Models + +public struct InternalRecommendationGroup: Encodable, Identifiable { + public let id: String + public let name: String + public let inviteUrl: String + + func asManagedObject(inContext context: NSManagedObjectContext) -> RecommendationGroup { + let fetchRequest: NSFetchRequest = RecommendationGroup.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", id + ) + let existing = (try? context.fetch(fetchRequest))?.first + let recommendationGroup = existing ?? RecommendationGroup(entity: RecommendationGroup.entity(), insertInto: context) + + recommendationGroup.id = id + recommendationGroup.name = name + recommendationGroup.inviteUrl = inviteUrl + + return recommendationGroup + } + + static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? { + if let id = recommendationGroup.id, + let name = recommendationGroup.name, + let inviteUrl = recommendationGroup.inviteUrl + { + return InternalRecommendationGroup( + id: id, + name: name, + inviteUrl: inviteUrl + ) + } + return nil + } +} + +extension Sequence where Element == InternalRecommendationGroup { + func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? { + var result: [NSManagedObjectID]? + + context.performAndWait { + let recommendationGroups = map { $0.asManagedObject(inContext: context) } + do { + try context.save() + logger.debug("InternalRecommendationGroup saved succesfully") + result = recommendationGroups.map(\.objectID) + } catch { + context.rollback() + logger.debug("Failed to save InternalRecommendationGroups: \(error.localizedDescription)") + } + } + + return result + } +} From 6622cd281ed295c5ad6b8b9d8b81fb4d50a7fae3 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 15:10:13 +0800 Subject: [PATCH 02/20] Remove unused view --- .../Views/AudioPlayer/ExpandedPlayer.swift | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift index cd6271ef5..1a8e7fa09 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift @@ -355,42 +355,6 @@ } } - var changeVoiceView: some View { - NavigationView { - VStack { - List { - ForEach(audioController.voiceList ?? [], id: \.key.self) { voice in - Button(action: { - audioController.currentVoice = voice.key - self.showVoiceSheet = false - }, label: { - HStack { - Text(voice.name) - - Spacer() - - if voice.selected { - Image(systemName: "checkmark") - } - } - .contentShape(Rectangle()) - }).buttonStyle(PlainButtonStyle()) - } - } - .padding(.top, 32) - .listStyle(.plain) - Spacer() - } - .navigationBarTitle("Voice") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: { - Image(systemName: "chevron.backward") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - })) - } - } - var scrubbing: Bool { switch audioController.scrubState { case .scrubStarted: From 691177284e2987aaaf710537fccfc11165a87101 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 15:10:29 +0800 Subject: [PATCH 03/20] Remove unused import --- packages/web/pages/home.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web/pages/home.tsx b/packages/web/pages/home.tsx index 1e1201f72..f222eb42f 100644 --- a/packages/web/pages/home.tsx +++ b/packages/web/pages/home.tsx @@ -1,7 +1,6 @@ import { PrimaryLayout } from '../components/templates/PrimaryLayout' import { HomeFeedContainer } from '../components/templates/homeFeed/HomeFeedContainer' import { VStack } from './../components/elements/LayoutPrimitives' -import { useRef } from 'react' export default function Home(): JSX.Element { return From d57cac47e70cae27e2d492440fede7853cd71a5c Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 15:45:36 +0800 Subject: [PATCH 04/20] Basic page for accepting invites --- .../networking/mutations/joinGroupMutation.ts | 51 +++++++++++++++++++ packages/web/pages/invite/[inviteCode].tsx | 45 ++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 packages/web/lib/networking/mutations/joinGroupMutation.ts create mode 100644 packages/web/pages/invite/[inviteCode].tsx diff --git a/packages/web/lib/networking/mutations/joinGroupMutation.ts b/packages/web/lib/networking/mutations/joinGroupMutation.ts new file mode 100644 index 000000000..86c7b9000 --- /dev/null +++ b/packages/web/lib/networking/mutations/joinGroupMutation.ts @@ -0,0 +1,51 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +export interface RecommendationGroup { + id: string + name: string + createdAt: Date + updatedAt: Date +} + +type JoinGroupResponse = { + joinGroup?: JoinGroupData + errorCodes?: string[] +} + +type JoinGroupData = { + group: RecommendationGroup | undefined +} + +export async function joinGroupMutation( + inviteCode: string +): Promise { + const mutation = gql` + mutation JoinGroup($inviteCode: String!) { + joinGroup(inviteCode: $inviteCode) { + ... on JoinGroupSuccess { + group { + id + name + createdAt + updatedAt + } + } + ... on JoinGroupError { + errorCodes + } + } + } + ` + + console.log('JoinGroupMutation', mutation) + + const response = await gqlFetcher(mutation, { inviteCode }) + console.log(' -- response', response) + const data = response as JoinGroupResponse | undefined + const error = data?.errorCodes?.find(() => true) + if (error) { + throw error + } + return data?.joinGroup?.group +} diff --git a/packages/web/pages/invite/[inviteCode].tsx b/packages/web/pages/invite/[inviteCode].tsx new file mode 100644 index 000000000..6e68a1684 --- /dev/null +++ b/packages/web/pages/invite/[inviteCode].tsx @@ -0,0 +1,45 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { PageMetaData } from '../../components/patterns/PageMetaData' +import { ProfileLayout } from '../../components/templates/ProfileLayout' +import { joinGroupMutation } from '../../lib/networking/mutations/joinGroupMutation' +import { showSuccessToast } from '../../lib/toastHelpers' + +export default function InvitePage(): JSX.Element { + const router = useRouter() + const { inviteCode } = router.query + + const [error, setError] = useState(undefined) + + useEffect(() => { + if (!router.isReady) { + return + } + if (!inviteCode || typeof inviteCode != 'string') { + setError('No invite code provided') + } + + const joinGroup = async () => { + try { + const result = await joinGroupMutation(inviteCode as string) + if (!result) { + throw 'Unknown error occurred.' + } + showSuccessToast(`You have been added to the ${result.name} group`) + } catch (error) { + setError('Unable to join group') + } + } + + joinGroup().catch((error) => { + setError(error) + }) + }, [router, inviteCode]) + return ( + <> + + Accepting invite to join +
+ + ) +} From c03c6e2a51cbf7683667a49dd1fd143b304e5bdc Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 17:44:48 +0800 Subject: [PATCH 05/20] Use a share view for copying the group invite link --- .../Profile/RecommendationGroupView.swift | 25 +++++++++++-------- .../App/Views/WebReader/RecommendToView.swift | 8 ++++++ .../DataService/Mutations/RecommendPage.swift | 8 ++++++ 3 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift index e4bdcba4e..21fe18470 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -29,6 +29,8 @@ struct RecommendationGroupView: View { @EnvironmentObject var dataService: DataService @StateObject var viewModel: RecommendationsGroupViewModel + @State var presentShareSheet = false + var body: some View { Group { #if os(iOS) @@ -45,6 +47,14 @@ struct RecommendationGroupView: View { .task { await viewModel.loadGroups(dataService: dataService) } } + private var shareView: some View { + if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) { + return AnyView(ShareSheet(activityItems: [shareLink])) + } else { + return AnyView(Text("Error copying invite URL")) + } + } + private var innerBody: some View { Group { Section("Name") { @@ -53,22 +63,15 @@ struct RecommendationGroupView: View { Section("Invite Link") { Button(action: { - #if os(iOS) - UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl - #endif - - #if os(macOS) - let pasteBoard = NSPasteboard.general - pasteBoard.clearContents() - pasteBoard.writeObjects([viewModel.recommendationGroup.inviteUrl as NSString]) - #endif - - Snackbar.show(message: "Invite link copied") + presentShareSheet = true }, label: { Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))") }) } } + .formSheet(isPresented: $presentShareSheet) { + shareView + } .navigationTitle(viewModel.recommendationGroup.name) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift new file mode 100644 index 000000000..aa2f9ef7e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Jackson Harper on 12/5/22. +// + +import Foundation diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift new file mode 100644 index 000000000..aa2f9ef7e --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Jackson Harper on 12/5/22. +// + +import Foundation From f2a0c540a635324491fdc19d20b669473245def0 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 17:45:06 +0800 Subject: [PATCH 06/20] iOS basic UI for recommending --- .../App/Views/WebReader/RecommendToView.swift | 121 +++++++++++++++++- .../Views/WebReader/WebReaderContainer.swift | 16 +++ .../DataService/Mutations/RecommendPage.swift | 54 +++++++- packages/db/migrate.ts | 2 +- 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift index aa2f9ef7e..902eae6aa 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -1,8 +1,115 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 12/5/22. -// +import Models +import Services +import SwiftUI +import Views -import Foundation +@MainActor final class RecommendToViewModel: ObservableObject { + @Published var isLoading = false + @Published var networkError = true + @Published var recommendationGroups = [InternalRecommendationGroup]() + @Published var selectedGroups = [String]() + @Published var isRunning = false + @Published var showError = false + + let pageID: String + + init(pageID: String) { + self.pageID = pageID + } + + func loadGroups(dataService: DataService) async { + isLoading = true + + do { + recommendationGroups = try await dataService.recommendationGroups() + } catch { + networkError = true + } + + isLoading = false + } + + func recommend(dataService: DataService) async { + isRunning = true + + do { + try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups) + } catch { + showError = true + } + + isRunning = false + } +} + +struct RecommendToView: View { + var dataService: DataService + @StateObject var viewModel: RecommendToViewModel + @Environment(\.dismiss) private var dismiss + + var nextButton: some View { + if viewModel.isRunning { + return AnyView(ProgressView()) + } else { + return AnyView(Button(action: { + Task { + await viewModel.recommend(dataService: dataService) + Snackbar.show(message: "Recommendation sent") + dismiss() + } + }, label: { + Text("Next") + }) + .disabled(viewModel.selectedGroups.isEmpty) + ) + } + } + + var body: some View { + VStack { + List { + ForEach(viewModel.recommendationGroups) { group in + HStack { + Text(group.name) + + Spacer() + + 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) + } + } + } + } + .listStyle(.grouped) + + Spacer() + } + .alert(isPresented: $viewModel.showError) { + Alert( + title: Text("Error recommending this page"), + dismissButton: .cancel(Text("Ok")) { + viewModel.showError = false + } + ) + } + .navigationBarTitle("Recommend To") + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + .navigationBarItems(leading: Button(action: { + dismiss() + }, label: { Text("Cancel") }), + trailing: nextButton) + .task { + await viewModel.loadGroups(dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 748246a6a..9da188077 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -29,6 +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 @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -219,6 +220,13 @@ struct WebReaderContainerView: View { action: delete, label: { Label("Delete", systemImage: "trash") } ) + Button( + action: { + // dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0) + displayRecommendSheet = true + }, + label: { Label("Recommend", systemImage: "sparkles") } + ) } } @@ -351,6 +359,14 @@ struct WebReaderContainerView: View { showErrorAlertMessage = false }) } + .formSheet(isPresented: $displayRecommendSheet) { + NavigationView { + RecommendToView( + dataService: dataService, + viewModel: RecommendToViewModel(pageID: item.unwrappedID) + ) + } + } .sheet(isPresented: $showHighlightAnnotationModal) { HighlightAnnotationSheet( annotation: $annotation, diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift index aa2f9ef7e..6716460db 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift @@ -1,8 +1,48 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 12/5/22. -// - +import CoreData import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func recommendPage(pageID: String, groupIDs: [String]) async throws { + enum MutationResult { + case saved(taskNames: [String]) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + recommendError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) }, + recommendSuccess: .init { + .saved(taskNames: try $0.taskNames()) + } + ) + } + + let mutation = Selection.Mutation { + try $0.recommend( + input: .init(groupIds: groupIDs, pageId: pageID), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case .saved: + continuation.resume() + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/packages/db/migrate.ts b/packages/db/migrate.ts index 8a4386940..01b6ef065 100755 --- a/packages/db/migrate.ts +++ b/packages/db/migrate.ts @@ -70,7 +70,7 @@ log(`Migrating to ${targetMigrationLabel}.\n`) const logAppliedMigrations = ( appliedMigrations: Postgrator.Migration[] ): void => { - if (appliedMigrations.length > 0) { + if (appliedMigrations && appliedMigrations.length > 0) { log( `Applied ${chalk.green( appliedMigrations.length.toString() From e555cc733a75b46ccce37264949fc07430243dea Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 18:40:33 +0800 Subject: [PATCH 07/20] More descriptive error messages --- packages/api/src/resolvers/article_saving_request/index.ts | 2 +- packages/api/src/resolvers/labels/index.ts | 2 +- packages/api/src/resolvers/user/index.ts | 2 +- packages/api/src/routers/auth/mobile/sign_up.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/resolvers/article_saving_request/index.ts b/packages/api/src/resolvers/article_saving_request/index.ts index 1b8f2764e..6a44d2b1a 100644 --- a/packages/api/src/resolvers/article_saving_request/index.ts +++ b/packages/api/src/resolvers/article_saving_request/index.ts @@ -42,7 +42,7 @@ export const createArticleSavingRequestResolver = authorized< articleSavingRequest: request, } } catch (err) { - console.log('error', err) + console.log('error saving article', err) if (isErrorWithCode(err)) { return { errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode], diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index 31daf9cb9..78cb75703 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -201,7 +201,7 @@ export const deleteLabelResolver = authorized< label, } } catch (error) { - log.error('error', error) + log.error('error deleting label', error) return { errorCodes: [DeleteLabelErrorCode.BadRequest], } diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index d9a6f3828..4450ad910 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -211,7 +211,7 @@ export const googleSignupResolver: ResolverFn< me: userDataToUser({ ...user, profile: { ...profile, private: false } }), } } catch (err) { - console.log('error', err) + console.log('error signing up with google', err) if (isErrorWithCode(err)) { return { errorCodes: [err.errorCode as SignupErrorCode] } } diff --git a/packages/api/src/routers/auth/mobile/sign_up.ts b/packages/api/src/routers/auth/mobile/sign_up.ts index 9bfc36c78..55d9e6dea 100644 --- a/packages/api/src/routers/auth/mobile/sign_up.ts +++ b/packages/api/src/routers/auth/mobile/sign_up.ts @@ -72,7 +72,7 @@ export async function createMobileEmailSignUpResponse( json: {}, } } catch (e) { - console.log('error', e) + console.log('error creating mobile email sign up response', e) return signUpFailedPayload } } From 8ac1afe277e66f66df7027d45b448964196f8e9b Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 18:41:08 +0800 Subject: [PATCH 08/20] Use cookie auth instead of header to not interfere with GCPs auth mechanisms --- packages/api/src/utils/createTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 66ade68e5..0d1256e56 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -423,7 +423,7 @@ export const enqueueRecommendation = async ( } const headers = { - Authorization: authToken, + Cookie: `auth=${authToken}`, } // If there is no Google Cloud Project Id exposed, it means that we are in local environment if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { From 56c04a35bfa78ffdd6fbeec6b53ec16a9143eef0 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 5 Dec 2022 21:14:12 +0800 Subject: [PATCH 09/20] 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) From da4f880ce48791312b808ff05e7505d03785dbb3 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 5 Dec 2022 09:43:37 -0800 Subject: [PATCH 10/20] bump apple apps to v1.22.0 --- apple/Omnivore.xcodeproj/project.pbxproj | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 3df71e308..0722253ee 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1307,7 +1307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1317,7 +1317,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1339,7 +1339,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1349,7 +1349,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1421,7 +1421,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1432,7 +1432,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1455,7 +1455,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1466,7 +1466,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1521,7 +1521,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1600,7 +1600,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1639,7 +1639,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1717,7 +1717,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1804,7 +1804,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1859,7 +1859,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1888,7 +1888,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; From 66fc258e0cf006e2c5e7e128d0311d1b126c32b6 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 10:58:01 +0800 Subject: [PATCH 11/20] Dont crash if clicking on a fragment link If a user clicks on a fragment link like click here to skip to info we would previously crash because we would reload the bundle. This prevents the crash but the link does not work, we can't just synthesize these links with JS, because the id="info" items are usually removed by readability. To properly handle this, we probably need to preserve some id elements if they are linked to. Then we can scroll to those elements in Javascript. --- .../Sources/App/Views/WebReader/WebReaderCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift index 99e9952d3..cffc3a7df 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -62,7 +62,7 @@ extension WebReaderCoordinator: WKNavigationDelegate { // swiftlint:disable:next line_length func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { - if let linkURL = navigationAction.request.url { + if let linkURL = navigationAction.request.url, !linkURL.isFileURL { linkHandler(linkURL) } decisionHandler(.cancel) From 19a5495bd37aa067129b7dea8612fe0d8fa4c54d Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 11:46:36 +0800 Subject: [PATCH 12/20] Reset showRecommendSheet properly so the form sheet can be re-opened --- .../Sources/App/Views/WebReader/WebReaderContainer.swift | 3 ++- .../Sources/Services/DataService/Mutations/SavePage.swift | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 36111be75..408da9ef7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -222,7 +222,6 @@ struct WebReaderContainerView: View { ) Button( action: { - // dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0) showRecommendSheet = true }, label: { Label("Recommend", systemImage: "sparkles") } @@ -365,6 +364,8 @@ struct WebReaderContainerView: View { dataService: dataService, viewModel: RecommendToViewModel(pageID: item.unwrappedID) ) + }.onDisappear { + showRecommendSheet = false } } .sheet(isPresented: $showHighlightAnnotationModal) { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift index 8b00c363e..bf4d9ffef 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift @@ -59,7 +59,6 @@ public extension DataService { } } case let .failure(error): - print("RESULT: ", result) continuation.resume(throwing: SaveArticleError.make(from: error)) } } From f4b101a12f000e09385b8c08e9e0ae8ffcfbaab4 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 13:35:46 +0800 Subject: [PATCH 13/20] Display members and admins in Recommendation Groups --- .../Profile/RecommendationGroupView.swift | 87 +++++++++++++++++-- .../Registration/CreateProfileView.swift | 12 +-- .../Registration/NewAppleSignupView.swift | 4 +- .../Views/Registration/RegistrationView.swift | 6 +- .../CoreDataModel.xcdatamodel/contents | 8 ++ .../Models/DataModels/NewUserProfile.swift | 52 +++++++++++ .../Models/DataModels/UserProfile.swift | 51 ++--------- .../Authentication/AccountCreator.swift | 6 +- .../Services/Authentication/AuthModels.swift | 4 +- .../RecommendationGroupSelection.swift | 4 +- .../Selections/UserProfileSelection.swift | 18 ++++ .../InternalRecommendationGroup.swift | 8 +- .../InternalModels/InternalUserProfile.swift | 63 ++++++++++++++ .../Sources/Views/ProfileCard.swift | 6 +- 14 files changed, 256 insertions(+), 73 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift create mode 100644 apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift index 21fe18470..30356f083 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -12,16 +12,58 @@ import Views self.recommendationGroup = recommendationGroup } - func loadGroups(dataService _: DataService) async { - isLoading = true + var nonAdmins: [InternalUserProfile] { + recommendationGroup.members.filter { member in + !recommendationGroup.admins.contains(where: { member.id == $0.id }) + } + } +} -// do { -// recommendationGroups = try await dataService.recommendationGroups() -// } catch { -// networkError = true -// } +private struct SmallUserCard: View { + let data: ProfileCardData + var size: CGFloat - isLoading = false + public init(data: ProfileCardData, size: CGFloat = 36) { + self.data = data + self.size = size + } + + public var body: some View { + HStack(alignment: .center, spacing: 16) { + Group { + AsyncImage( + url: data.imageURL, + content: { $0.resizable() }, + placeholder: { + Image(systemName: "person.crop.circle") + .resizable() + .foregroundColor(.appGrayTextContrast) + } + ) + } + .padding(.leading, 0) + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size, alignment: .center) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(data.name) + .font(.appBody) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("@\(data.username)") + .font(.appCaption) + .foregroundColor(.appGrayText) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.leading) + .padding(0) + } + .frame(maxWidth: .infinity) } } @@ -44,7 +86,6 @@ struct RecommendationGroupView: View { .listStyle(InsetListStyle()) #endif } - .task { await viewModel.loadGroups(dataService: dataService) } } private var shareView: some View { @@ -55,6 +96,30 @@ struct RecommendationGroupView: View { } } + private var adminsSection: some View { + Section("Admins") { + ForEach(viewModel.recommendationGroup.admins) { admin in + SmallUserCard(data: ProfileCardData( + name: admin.name, + username: admin.username, + imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil + )) + } + } + } + + private var membersSection: some View { + Section("Members") { + ForEach(viewModel.nonAdmins) { member in + SmallUserCard(data: ProfileCardData( + name: member.name, + username: member.username, + imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil + )) + } + } + } + private var innerBody: some View { Group { Section("Name") { @@ -66,8 +131,12 @@ struct RecommendationGroupView: View { presentShareSheet = true }, label: { Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))") + .font(.appCaption) }) } + + adminsSection + membersSection } .formSheet(isPresented: $presentShareSheet) { shareView diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index 4164550fb..bc60ce80e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -6,7 +6,7 @@ import Utils import Views @MainActor final class CreateProfileViewModel: ObservableObject { - private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil) + private(set) var initialUserProfile = NewUserProfile(username: "", name: "", bio: nil) var isConfigured = false var hasSuggestedProfile: Bool { @@ -31,7 +31,7 @@ import Views init() {} func submitProfile(name: String, bio: String, authenticator: Authenticator) { - let profileOrError = UserProfile.make( + let profileOrError = NewUserProfile.make( username: potentialUsername, name: name, bio: bio.isEmpty ? nil : bio @@ -77,7 +77,7 @@ import Views } } - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -87,7 +87,7 @@ import Views } } - func configure(profile: UserProfile, dataService: DataService) { + func configure(profile: NewUserProfile, dataService: DataService) { guard !isConfigured else { return } isConfigured = true @@ -104,7 +104,7 @@ import Views } struct CreateProfileView: View { - private let initialUserProfile: UserProfile + private let initialUserProfile: NewUserProfile @State private var isConfigured = false @Environment(\.horizontalSizeClass) var horizontalSizeClass @EnvironmentObject var authenticator: Authenticator @@ -114,7 +114,7 @@ struct CreateProfileView: View { @State private var name = "" @State private var bio = "" - init(userProfile: UserProfile) { + init(userProfile: NewUserProfile) { self.initialUserProfile = userProfile } diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift index 3bb561ac5..3eda742f1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift @@ -9,7 +9,7 @@ import Views init() {} - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -23,7 +23,7 @@ import Views struct NewAppleSignupView: View { @EnvironmentObject var authenticator: Authenticator @StateObject private var viewModel = NewAppleSignupViewModel() - let userProfile: UserProfile + let userProfile: NewUserProfile let showProfileEditView: () -> Void var body: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index 8bd5754ab..db6250caf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -7,8 +7,8 @@ import Views @MainActor final class RegistrationViewModel: ObservableObject { enum RegistrationState { - case createProfile(userProfile: UserProfile) - case newAppleSignUp(userProfile: UserProfile) + case createProfile(userProfile: NewUserProfile) + case newAppleSignUp(userProfile: NewUserProfile) } @Published var loginError: LoginError? @@ -65,7 +65,7 @@ import Views case let .loginError(error): loginError = error case .newOmnivoreUser: - registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) + registrationState = .createProfile(userProfile: NewUserProfile(username: "", name: "")) case .existingOmnivoreUser: break } 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 0e08359d7..9dbaf53aa 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -104,6 +104,14 @@ + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift new file mode 100644 index 000000000..a13c52bcb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift @@ -0,0 +1,52 @@ +import Foundation +import Utils + +public struct NewUserProfile: Codable { + public let username: String + public let name: String + public let bio: String? + + public init( + username: String, + name: String, + bio: String? = nil + ) { + self.username = username + self.name = name + self.bio = bio + } +} + +public extension NewUserProfile { + static func make( + username: String, + name: String, + bio: String? + ) -> Either { + let userProfile = NewUserProfile( + username: username, + name: name, + bio: bio + ) + + if let errorMessage = userProfile.validationErrorMessage { + return .right(errorMessage) + } else { + return .left(userProfile) + } + } + + private var validationErrorMessage: String? { + if name.isEmpty { + return "The name field is missing." + } + + return nil + } +} + +public extension Viewer { + var unwrappedUsername: String { username ?? "" } + var unwrappedName: String { name ?? "" } + var unwrappedUserID: String { userID ?? "" } +} diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift index 18e0a3da1..7c655577f 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift @@ -1,52 +1,19 @@ +import CoreData import Foundation -import Utils - -public struct UserProfile: Codable { - public let username: String - public let name: String - public let bio: String? - - public init( - username: String, - name: String, - bio: String? = nil - ) { - self.username = username - self.name = name - self.bio = bio - } -} public extension UserProfile { - static func make( - username: String, - name: String, - bio: String? - ) -> Either { - let userProfile = UserProfile( - username: username, - name: name, - bio: bio + static func lookup(byID userID: String, inContext context: NSManagedObjectContext) -> UserProfile? { + let fetchRequest: NSFetchRequest = UserProfile.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "%K == %@", #keyPath(UserProfile.userID), userID ) - if let errorMessage = userProfile.validationErrorMessage { - return .right(errorMessage) - } else { - return .left(userProfile) - } - } + var result: UserProfile? - private var validationErrorMessage: String? { - if name.isEmpty { - return "The name field is missing." + context.performAndWait { + result = (try? context.fetch(fetchRequest))?.first } - return nil + return result } } - -public extension Viewer { - var unwrappedUsername: String { username ?? "" } - var unwrappedName: String { name ?? "" } - var unwrappedUserID: String { userID ?? "" } -} diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift index 73e31dbb7..1db37f136 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift @@ -5,12 +5,12 @@ public extension Authenticator { func createPendingAccountUsingApple( token: String, name: PersonNameComponents? - ) async throws -> UserProfile { + ) async throws -> NewUserProfile { let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name) return try await createPendingAccount(params: params) } - func createAccount(userProfile: UserProfile) async throws { + func createAccount(userProfile: NewUserProfile) async throws { let params = CreateAccountParams( pendingUserToken: pendingUserToken ?? "", userProfile: userProfile @@ -34,7 +34,7 @@ public extension Authenticator { } extension Authenticator { - func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile { + func createPendingAccount(params: CreatePendingAccountParams) async throws -> NewUserProfile { do { let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams) diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index 454e06b1e..550389c56 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -48,12 +48,12 @@ struct EmailAuthPayload: Decodable { struct CreateAccountParams: Encodable { let pendingUserToken: String - let userProfile: UserProfile + let userProfile: NewUserProfile } struct PendingUserAuthPayload: Decodable { let pendingUserToken: String - let pendingUserProfile: UserProfile + let pendingUserProfile: NewUserProfile } struct PendingEmailVerificationAuthPayload: Decodable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift index 4064c1bf9..7759f9814 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift @@ -5,6 +5,8 @@ let recommendationGroupSelection = Selection.RecommendationGroup { InternalRecommendationGroup( id: try $0.id(), name: try $0.name(), - inviteUrl: try $0.inviteUrl() + inviteUrl: try $0.inviteUrl(), + admins: try $0.admins(selection: userProfileSelection.list), + members: try $0.members(selection: userProfileSelection.list) ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift new file mode 100644 index 000000000..ad9562955 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift @@ -0,0 +1,18 @@ +import Models +import SwiftGraphQL + +let profileSelection = Selection.Profile { + ( + username: try $0.username(), + profileImageURL: try $0.pictureUrl() + ) +} + +let userProfileSelection = Selection.User { + InternalUserProfile( + userID: try $0.id(), + name: try $0.name(), + username: try $0.profile(selection: profileSelection).username, + profileImageURL: try $0.profile(selection: profileSelection).profileImageURL + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift index c787cc1f8..ff90cc253 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift @@ -9,10 +9,12 @@ import CoreData import Foundation import Models -public struct InternalRecommendationGroup: Encodable, Identifiable { +public struct InternalRecommendationGroup: Identifiable { public let id: String public let name: String public let inviteUrl: String + public let admins: [InternalUserProfile] + public let members: [InternalUserProfile] func asManagedObject(inContext context: NSManagedObjectContext) -> RecommendationGroup { let fetchRequest: NSFetchRequest = RecommendationGroup.fetchRequest() @@ -37,7 +39,9 @@ public struct InternalRecommendationGroup: Encodable, Identifiable { return InternalRecommendationGroup( id: id, name: name, - inviteUrl: inviteUrl + inviteUrl: inviteUrl, + admins: InternalUserProfile.make(recommendationGroup.admins), + members: InternalUserProfile.make(recommendationGroup.members) ) } return nil diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift new file mode 100644 index 000000000..75a2924a8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift @@ -0,0 +1,63 @@ +import CoreData +import Foundation +import Models + +public struct InternalUserProfile: Identifiable { + let userID: String + public let name: String + public let username: String + public let profileImageURL: String? + + public var id: String { + userID + } + + func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { + var objectID: NSManagedObjectID? + + context.performAndWait { + let user = asManagedObject(inContext: context) + + do { + try context.save() + logger.debug("User saved succesfully") + objectID = user.objectID + } catch { + context.rollback() + logger.debug("Failed to save User: \(error.localizedDescription)") + } + } + + return objectID + } + + func asManagedObject(inContext context: NSManagedObjectContext) -> UserProfile { + let existing = UserProfile.lookup(byID: userID, inContext: context) + let userProfile = existing ?? UserProfile(entity: UserProfile.entity(), insertInto: context) + + userProfile.userID = userID + userProfile.name = name + userProfile.username = username + userProfile.profileImageURL = profileImageURL + return userProfile + } + + public static func make(_ users: NSSet?) -> [InternalUserProfile] { + users? + .compactMap { user in + if let user = user as? UserProfile, + let userID = user.userID, + let name = user.name, + let username = user.username + { + return InternalUserProfile( + userID: userID, + name: name, + username: username, + profileImageURL: user.profileImageURL + ) + } + return nil + } ?? [] + } +} diff --git a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift index 9e923a560..dc1dc69aa 100644 --- a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift +++ b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift @@ -1,9 +1,9 @@ import SwiftUI public struct ProfileCardData { - let name: String - let username: String - let imageURL: URL? + public let name: String + public let username: String + public let imageURL: URL? public init(name: String = "", username: String = "", imageURL: URL? = nil) { self.name = name From 81dd49d6af649ac3ef295640a0e6c940acb67602 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 13:42:39 +0800 Subject: [PATCH 14/20] Move groups section into the rest of the user objects on the profile --- .../Sources/App/Views/Profile/ProfileView.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 10ecb95af..ffa30831d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -97,6 +97,10 @@ struct ProfileView: View { NavigationLink(destination: SubscriptionsView()) { Text("Subscriptions") } + + NavigationLink(destination: GroupsView()) { + Text("Recommendation Groups") + } } #if os(iOS) @@ -107,12 +111,6 @@ struct ProfileView: View { } #endif - Section { - NavigationLink(destination: GroupsView()) { - Text("Recommendation Groups") - } - } - Section { NavigationLink( destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL) From 0892d9e7e6fa7cf53bf9365e68bb3b679d504738 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 17:44:53 +0800 Subject: [PATCH 15/20] Add user and note to Recommendation type --- packages/api/src/elastic/types.ts | 9 ++++ packages/api/src/generated/graphql.ts | 24 +++++++++++ packages/api/src/generated/schema.graphql | 10 +++++ .../src/resolvers/recommendations/index.ts | 13 +++++- packages/api/src/schema.ts | 10 +++++ .../db/elastic_migrations/index_settings.json | 36 ++++++++++++++-- .../queries/useGetLibraryItemsQuery.tsx | 42 ++++++++++++++++--- 7 files changed, 132 insertions(+), 12 deletions(-) diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index de2a0dd58..ade5f5f1f 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -198,9 +198,18 @@ export interface Highlight { highlightPositionAnchorIndex?: number | null } +export interface RecommendingUser { + userId: string + name: string + username: string + profileImageURL: string | null +} + export interface Recommendation { id: string name: string + note: string | null + user: RecommendingUser recommendedAt: Date } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index d254c1547..c68407531 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1787,6 +1787,7 @@ export enum RecommendErrorCode { export type RecommendInput = { groupIds: Array; + note?: InputMaybe; pageId: Scalars['ID']; }; @@ -1801,7 +1802,9 @@ export type Recommendation = { __typename?: 'Recommendation'; id: Scalars['ID']; name: Scalars['String']; + note?: Maybe; recommendedAt: Scalars['Date']; + user: RecommendingUser; }; export type RecommendationGroup = { @@ -1815,6 +1818,14 @@ export type RecommendationGroup = { updatedAt: Scalars['Date']; }; +export type RecommendingUser = { + __typename?: 'RecommendingUser'; + name: Scalars['String']; + profileImageURL?: Maybe; + userId: Scalars['String']; + username: Scalars['String']; +}; + export type Reminder = { __typename?: 'Reminder'; archiveUntil: Scalars['Boolean']; @@ -3230,6 +3241,7 @@ export type ResolversTypes = { RecommendSuccess: ResolverTypeWrapper; Recommendation: ResolverTypeWrapper; RecommendationGroup: ResolverTypeWrapper; + RecommendingUser: ResolverTypeWrapper; Reminder: ResolverTypeWrapper; ReminderError: ResolverTypeWrapper; ReminderErrorCode: ReminderErrorCode; @@ -3631,6 +3643,7 @@ export type ResolversParentTypes = { RecommendSuccess: RecommendSuccess; Recommendation: Recommendation; RecommendationGroup: RecommendationGroup; + RecommendingUser: RecommendingUser; Reminder: Reminder; ReminderError: ReminderError; ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess']; @@ -4836,7 +4849,9 @@ export type RecommendSuccessResolvers = { id?: Resolver; name?: Resolver; + note?: Resolver, ParentType, ContextType>; recommendedAt?: Resolver; + user?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4851,6 +4866,14 @@ export type RecommendationGroupResolvers; }; +export type RecommendingUserResolvers = { + name?: Resolver; + profileImageURL?: Resolver, ParentType, ContextType>; + userId?: Resolver; + username?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ReminderResolvers = { archiveUntil?: Resolver; id?: Resolver; @@ -5718,6 +5741,7 @@ export type Resolvers = { RecommendSuccess?: RecommendSuccessResolvers; Recommendation?: RecommendationResolvers; RecommendationGroup?: RecommendationGroupResolvers; + RecommendingUser?: RecommendingUserResolvers; Reminder?: ReminderResolvers; ReminderError?: ReminderErrorResolvers; ReminderResult?: ReminderResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 00e4a1c98..7178aa080 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1250,6 +1250,7 @@ enum RecommendErrorCode { input RecommendInput { groupIds: [ID!]! + note: String pageId: ID! } @@ -1262,7 +1263,9 @@ type RecommendSuccess { type Recommendation { id: ID! name: String! + note: String recommendedAt: Date! + user: RecommendingUser! } type RecommendationGroup { @@ -1275,6 +1278,13 @@ type RecommendationGroup { updatedAt: Date! } +type RecommendingUser { + name: String! + profileImageURL: String + userId: String! + username: String! +} + type Reminder { archiveUntil: Boolean! id: ID! diff --git a/packages/api/src/resolvers/recommendations/index.ts b/packages/api/src/resolvers/recommendations/index.ts index b9083844c..d86d45bde 100644 --- a/packages/api/src/resolvers/recommendations/index.ts +++ b/packages/api/src/resolvers/recommendations/index.ts @@ -146,8 +146,9 @@ export const recommendResolver = authorized< }) try { - const user = await getRepository(User).findOneBy({ - id: uid, + const user = await getRepository(User).findOne({ + where: { id: uid }, + relations: ['profile'], }) if (!user) { return { @@ -185,6 +186,13 @@ export const recommendResolver = authorized< page.id, { ...group, + note: input.note ?? null, + user: { + userId: user.id, + name: user.name, + username: user.profile.username, + profileImageURL: user.profile.pictureUrl, + }, recommendedAt: new Date(), }, auth @@ -198,6 +206,7 @@ export const recommendResolver = authorized< taskNames, } } catch (error) { + console.log('Error recommending: ', error) log.error('Error recommending', { error, labels: { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index fc1267142..a281a70de 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -329,10 +329,19 @@ const schema = gql` updatedAt: Date! } + type RecommendingUser { + userId: String! + name: String! + username: String! + profileImageURL: String + } + type Recommendation { id: ID! name: String! + user: RecommendingUser recommendedAt: Date! + note: String } type Article { @@ -2194,6 +2203,7 @@ const schema = gql` input RecommendInput { pageId: ID! groupIds: [ID!]! + note: String } union RecommendResult = RecommendSuccess | RecommendError diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index 5d4018d4b..8cd30e7dc 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -7,13 +7,19 @@ "analyzer": { "strip_html_analyzer": { "tokenizer": "standard", - "char_filter": ["html_strip"], - "filter": ["lowercase"] + "char_filter": [ + "html_strip" + ], + "filter": [ + "lowercase" + ] } }, "normalizer": { "lowercase_normalizer": { - "filter": ["lowercase"] + "filter": [ + "lowercase" + ] } } } @@ -173,9 +179,31 @@ }, "recommendedAt": { "type": "date" + }, + "note": { + "type": "keyword" + }, + "user": { + "type": "nested", + "properties": { + "userID": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "username": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "profileImageURL": { + "type": "keyword" + } + } } } } } } -} +} \ No newline at end of file diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index 001842abe..287ee1a43 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -80,8 +80,24 @@ export type LibraryItemNode = { state: State pageType: PageType siteName?: string - subscription?: string, + subscription?: string readAt?: string + recommendedBy?: Recommendation[] +} + +export type Recommendation = { + id: string + name: string + note?: string + user?: RecommendingUser + recommendedAt: Date +} + +export type RecommendingUser = { + userId: string + name: string + username: string + profileImageURL?: string } export type PageInfo = { @@ -135,6 +151,18 @@ export function useGetLibraryItemsQuery({ siteName subscription readAt + recommendedBy { + id + name + note + user { + userId + name + username + profileImageURL + } + recommendedAt + } } } pageInfo { @@ -198,7 +226,7 @@ export function useGetLibraryItemsQuery({ } const getIndexOf = (page: LibraryItems, item: LibraryItem) => { - return page.edges.findIndex(i => i.node.id === item.node.id) + return page.edges.findIndex((i) => i.node.id === item.node.id) } const performActionOnItem = async ( @@ -329,7 +357,9 @@ export function useGetLibraryItemsQuery({ }) unsubscribeMutation(item.node.subscription).then((res) => { if (res) { - showSuccessToast('Unsubscribed successfully', { position: 'bottom-right' }) + showSuccessToast('Unsubscribed successfully', { + position: 'bottom-right', + }) } else { showErrorToast('Error unsubscribing', { position: 'bottom-right', @@ -337,8 +367,8 @@ export function useGetLibraryItemsQuery({ } }) } - case 'update-item': - updateData(item) + case 'update-item': + updateData(item) break case 'refresh': await mutate() @@ -353,6 +383,6 @@ export function useGetLibraryItemsQuery({ performActionOnItem, size, setSize, - mutate + mutate, } } From ba8cd2261199c69de2a65a23703301ae4cc55a8e Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 17:51:03 +0800 Subject: [PATCH 16/20] Use text instead of keyword for recommendation notes --- packages/db/elastic_migrations/index_settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index 8cd30e7dc..ec53bce1a 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -181,7 +181,7 @@ "type": "date" }, "note": { - "type": "keyword" + "type": "text" }, "user": { "type": "nested", From a8df43929767f31b7df0127d92e9fa05b3d6b524 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 21:18:21 +0800 Subject: [PATCH 17/20] Rename recommendedBy to recommendations --- .../App/Views/WebReader/RecommendToView.swift | 3 +- .../CoreDataModel.xcdatamodel/contents | 5 +- .../Models/CoreData/StorageProvider.swift | 1 + .../Services/DataService/GQLSchema.swift | 205 ++++++++++++++++- .../DataService/Mutations/RecommendPage.swift | 4 +- .../Queries/ArticleContentQuery.swift | 2 +- .../Queries/LinkedItemNetworkQuery.swift | 40 +++- .../InternalModels/InternalLinkedItem.swift | 12 +- .../InternalRecommendation.swift | 7 +- .../InternalModels/InternalUserProfile.swift | 16 ++ .../Views/FeedItem/HomeFeedCardView.swift | 12 +- packages/api/src/elastic/recommendation.ts | 8 +- packages/api/src/elastic/types.ts | 4 +- packages/api/src/generated/graphql.ts | 12 +- packages/api/src/generated/schema.graphql | 6 +- packages/api/src/resolvers/article/index.ts | 1 + .../src/resolvers/recommendations/index.ts | 3 +- packages/api/src/schema.ts | 4 +- .../db/elastic_migrations/index_settings.json | 2 +- packages/db/migrate.ts | 211 +++++++++++++----- .../queries/useGetLibraryItemsQuery.tsx | 4 +- 21 files changed, 453 insertions(+), 109 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift index 24b624a91..20d737d03 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -10,6 +10,7 @@ import Views @Published var selectedGroups = [String]() @Published var isRunning = false @Published var showError = false + @Published var note: String? let pageID: String @@ -34,7 +35,7 @@ import Views isRunning = true do { - try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups) + try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups, note: note) } catch { showError = true } 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 9dbaf53aa..deb3afd4f 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -55,7 +55,7 @@ - + @@ -96,7 +96,8 @@ - + + diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift b/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift index 6198ec852..12a31526a 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift +++ b/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift @@ -21,6 +21,7 @@ public class PersistentContainer: NSPersistentContainer { #endif let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite") + print("starting with sqlite container", appGroupContainerURL) container.persistentStoreDescriptions.first!.url = appGroupContainerURL diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 33c18d55a..22963144d 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -684,7 +684,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] - let recommendedBy: [String: [Objects.Recommendation]] + let recommendations: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let savedByViewer: [String: Bool] let shareInfo: [String: Objects.LinkShareInfo] @@ -807,7 +807,7 @@ 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": + case "recommendations": if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } @@ -903,7 +903,7 @@ extension Objects.Article: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] - recommendedBy = map["recommendedBy"] + recommendations = map["recommendations"] savedAt = map["savedAt"] savedByViewer = map["savedByViewer"] shareInfo = map["shareInfo"] @@ -1282,9 +1282,9 @@ extension Fields where TypeLock == Objects.Article { } } - func recommendedBy(selection: Selection) throws -> Type { + func recommendations(selection: Selection) throws -> Type { let field = GraphQLField.composite( - name: "recommendedBy", + name: "recommendations", arguments: [], selection: selection.selection ) @@ -1292,7 +1292,7 @@ extension Fields where TypeLock == Objects.Article { switch response { case let .decoding(data): - return try selection.decode(data: data.recommendedBy[field.alias!]) + return try selection.decode(data: data.recommendations[field.alias!]) case .mocking: return selection.mock() } @@ -13733,7 +13733,9 @@ extension Objects { let __typename: TypeName = .recommendation let id: [String: String] let name: [String: String] + let note: [String: String] let recommendedAt: [String: DateTime] + let user: [String: Objects.RecommendingUser] enum TypeName: String, Codable { case recommendation = "Recommendation" @@ -13761,10 +13763,18 @@ extension Objects.Recommendation: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "note": + 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) } + case "user": + if let value = try container.decode(Objects.RecommendingUser?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } default: throw DecodingError.dataCorrupted( DecodingError.Context( @@ -13777,7 +13787,9 @@ extension Objects.Recommendation: Decodable { id = map["id"] name = map["name"] + note = map["note"] recommendedAt = map["recommendedAt"] + user = map["user"] } } @@ -13818,6 +13830,21 @@ extension Fields where TypeLock == Objects.Recommendation { } } + func note() throws -> String? { + let field = GraphQLField.leaf( + name: "note", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.note[field.alias!] + case .mocking: + return nil + } + } + func recommendedAt() throws -> DateTime { let field = GraphQLField.leaf( name: "recommendedAt", @@ -13835,6 +13862,22 @@ extension Fields where TypeLock == Objects.Recommendation { return DateTime.mockValue } } + + func user(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "user", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.user[field.alias!]) + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -14052,6 +14095,140 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecommendationGroup = Selection } +extension Objects { + struct RecommendingUser { + let __typename: TypeName = .recommendingUser + let name: [String: String] + let profileImageUrl: [String: String] + let userId: [String: String] + let username: [String: String] + + enum TypeName: String, Codable { + case recommendingUser = "RecommendingUser" + } + } +} + +extension Objects.RecommendingUser: 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 "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "profileImageUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "userId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "username": + if let value = try container.decode(String?.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)." + ) + ) + } + } + + name = map["name"] + profileImageUrl = map["profileImageUrl"] + userId = map["userId"] + username = map["username"] + } +} + +extension Fields where TypeLock == Objects.RecommendingUser { + 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 profileImageUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "profileImageURL", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.profileImageUrl[field.alias!] + case .mocking: + return nil + } + } + + func userId() throws -> String { + let field = GraphQLField.leaf( + name: "userId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.userId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func username() throws -> String { + let field = GraphQLField.leaf( + name: "username", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.username[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendingUser = Selection +} + extension Objects { struct Reminder { let __typename: TypeName = .reminder @@ -15471,7 +15648,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] - let recommendedBy: [String: [Objects.Recommendation]] + let recommendations: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let shortId: [String: String] let siteIcon: [String: String] @@ -15584,7 +15761,7 @@ 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": + case "recommendations": if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } @@ -15670,7 +15847,7 @@ extension Objects.SearchItem: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] - recommendedBy = map["recommendedBy"] + recommendations = map["recommendations"] savedAt = map["savedAt"] shortId = map["shortId"] siteIcon = map["siteIcon"] @@ -16011,9 +16188,9 @@ extension Fields where TypeLock == Objects.SearchItem { } } - func recommendedBy(selection: Selection) throws -> Type { + func recommendations(selection: Selection) throws -> Type { let field = GraphQLField.composite( - name: "recommendedBy", + name: "recommendations", arguments: [], selection: selection.selection ) @@ -16021,7 +16198,7 @@ extension Fields where TypeLock == Objects.SearchItem { switch response { case let .decoding(data): - return try selection.decode(data: data.recommendedBy[field.alias!]) + return try selection.decode(data: data.recommendations[field.alias!]) case .mocking: return selection.mock() } @@ -30128,16 +30305,20 @@ extension InputObjects { struct RecommendInput: Encodable, Hashable { var groupIds: [String] + var note: OptionalArgument = .absent() + var pageId: String func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(groupIds, forKey: .groupIds) + if note.hasValue { try container.encode(note, forKey: .note) } try container.encode(pageId, forKey: .pageId) } enum CodingKeys: String, CodingKey { case groupIds + case note case pageId } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift index 6716460db..68f604817 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift @@ -4,7 +4,7 @@ import Models import SwiftGraphQL public extension DataService { - func recommendPage(pageID: String, groupIDs: [String]) async throws { + func recommendPage(pageID: String, groupIDs: [String], note: String?) async throws { enum MutationResult { case saved(taskNames: [String]) case error(errorMessage: String) @@ -21,7 +21,7 @@ public extension DataService { let mutation = Selection.Mutation { try $0.recommend( - input: .init(groupIds: groupIDs, pageId: pageID), + input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID), selection: selection ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index bf7f5f639..a9170e38e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -44,7 +44,7 @@ extension DataService { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), - recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [], + recommendations: try $0.recommendations(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 6bb543079..51ded19f7 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -224,12 +224,38 @@ extension DataService { } } +let recommendingUserSelection = Selection.RecommendingUser { + do { + return InternalUserProfile( + userID: try $0.userId(), + name: try $0.name(), + username: try $0.username(), + profileImageURL: nil // try $0.profileImageUrl() ?? nil + ) + } catch { + print("ERROR WITH recommendingUserSelection", error) + throw error + } +} + let recommendationSelection = Selection.Recommendation { - InternalRecommendation( - id: try $0.id(), - name: try $0.name(), - recommendedAt: try $0.recommendedAt().value ?? Date() - ) + do { + let result = InternalRecommendation( + id: try $0.id(), + name: try $0.name(), + user: try $0.user(selection: recommendingUserSelection.nullable), + recommendedAt: try $0.recommendedAt().value ?? Date() + ) + return result + } catch { + print("ERROR WITH recommendationSelection", error) + throw error + } +} + +private func emptyrecommended() -> [InternalRecommendation] { + print("got the empty InternalRecommendation") + return [] } private let libraryArticleSelection = Selection.Article { @@ -257,7 +283,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) ?? [], + recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? emptyrecommended(), labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } @@ -295,7 +321,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) ?? [], + recommendations: try $0.recommendations(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 77ee4ae91..46827adf8 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -26,7 +26,7 @@ struct InternalLinkedItem { let contentReader: String? let originalHtml: String? let language: String? - let recommendedBy: [InternalRecommendation] + let recommendations: [InternalRecommendation] var labels: [InternalLinkedItemLabel] var isPDF: Bool { @@ -73,12 +73,12 @@ struct InternalLinkedItem { linkedItem.addToLabels(label.asManagedObject(inContext: context)) } - if let existingRecommendation = linkedItem.recommendedBy { - linkedItem.removeFromRecommendedBy(existingRecommendation) + if let existingRecommendation = linkedItem.recommendations { + linkedItem.removeFromRecommendations(existingRecommendation) } - for recommendation in recommendedBy { - linkedItem.addToRecommendedBy(recommendation.asManagedObject(inContext: context)) + for recommendation in recommendations { + linkedItem.addToRecommendations(recommendation.asManagedObject(inContext: context)) } return linkedItem @@ -142,7 +142,7 @@ extension JSONArticle { contentReader: contentReader, originalHtml: nil, language: language, - recommendedBy: [], // TODO: + recommendations: [], // TODO: labels: [] ) diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift index c706d7014..80a33b0cc 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift @@ -2,10 +2,11 @@ import CoreData import Foundation import Models -public struct InternalRecommendation: Encodable { +public struct InternalRecommendation { let id: String let name: String - let recommendedAt: Date? + let user: InternalUserProfile? + let recommendedAt: Date func asManagedObject(inContext context: NSManagedObjectContext) -> Recommendation { let existing = Recommendation.lookup(byID: id, inContext: context) @@ -13,6 +14,7 @@ public struct InternalRecommendation: Encodable { recommendation.id = id recommendation.name = name recommendation.recommendedAt = recommendedAt + recommendation.user = user?.asManagedObject(inContext: context) return recommendation } @@ -27,6 +29,7 @@ public struct InternalRecommendation: Encodable { return InternalRecommendation( id: id, name: name, + user: InternalUserProfile.makeSingle(recommendation.user), recommendedAt: recommendedAt ) } diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift index 75a2924a8..53b656890 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift @@ -42,6 +42,22 @@ public struct InternalUserProfile: Identifiable { return userProfile } + public static func makeSingle(_ user: UserProfile?) -> InternalUserProfile? { + if let user = user, + let userID = user.userID, + let name = user.name, + let username = user.username + { + return InternalUserProfile( + userID: userID, + name: name, + username: username, + profileImageURL: user.profileImageURL + ) + } + return nil + } + public static func make(_ users: NSSet?) -> [InternalUserProfile] { users? .compactMap { user in diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 1a7d04bd6..027bc4427 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -87,15 +87,21 @@ public struct FeedCard: View { #endif } - if let recommendedBy = item.recommendedBy, recommendedBy.count > 0 { - let str = recommendedBy.reduce("") { str, item in + if let recommendations = item.recommendations, recommendations.count > 0 { + let byStr = recommendations.reduce("") { str, item in + if let item = item as? Recommendation, let name = item.user?.name { + return str + name + } + return str + } + let inStr = recommendations.reduce("") { str, item in if let item = item as? Recommendation, let name = item.name { return str + name } return str } HStack { - Text("Recommended in \(str)") + Text("Recommended by \(byStr) in \(inStr)") .font(.appCaption) .frame(alignment: .leading) Spacer() diff --git a/packages/api/src/elastic/recommendation.ts b/packages/api/src/elastic/recommendation.ts index 511b3fae1..924f444fe 100644 --- a/packages/api/src/elastic/recommendation.ts +++ b/packages/api/src/elastic/recommendation.ts @@ -13,19 +13,19 @@ export const addRecommendation = async ( url: page.url, }) if (existingPage) { - if (existingPage.recommendedBy?.includes(recommendation)) { + if (existingPage.recommendations?.includes(recommendation)) { return existingPage._id } // update recommendedBy in the existing page - const recommendedBy = (existingPage.recommendedBy || []).concat( + const recommendations = (existingPage.recommendations || []).concat( recommendation ) await updatePage( existingPage.id, { - recommendedBy, + recommendations, }, ctx ) @@ -36,7 +36,7 @@ export const addRecommendation = async ( const newPage: Page = { ...page, id: '', - recommendedBy: [recommendation], + recommendations: [recommendation], userId: ctx.uid, readingProgressPercent: 0, readingProgressAnchorIndex: 0, diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index ade5f5f1f..e4164b9c3 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -249,7 +249,7 @@ export interface Page { readAt?: Date listenedAt?: Date wordsCount?: number - recommendedBy?: Recommendation[] + recommendations?: Recommendation[] } export interface SearchItem { @@ -282,7 +282,7 @@ export interface SearchItem { wordsCount?: number siteName?: string siteIcon?: string - recommendedBy?: Recommendation[] + recommendations?: Recommendation[] } const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index c68407531..8728bb795 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -110,7 +110,7 @@ export type Article = { readAt?: Maybe; readingProgressAnchorIndex: Scalars['Int']; readingProgressPercent: Scalars['Float']; - recommendedBy?: Maybe>; + recommendations?: Maybe>; savedAt: Scalars['Date']; savedByViewer?: Maybe; shareInfo?: Maybe; @@ -1804,7 +1804,7 @@ export type Recommendation = { name: Scalars['String']; note?: Maybe; recommendedAt: Scalars['Date']; - user: RecommendingUser; + user?: Maybe; }; export type RecommendationGroup = { @@ -2057,7 +2057,7 @@ export type SearchItem = { readAt?: Maybe; readingProgressAnchorIndex: Scalars['Int']; readingProgressPercent: Scalars['Float']; - recommendedBy?: Maybe>; + recommendations?: Maybe>; savedAt: Scalars['Date']; shortId?: Maybe; siteIcon?: Maybe; @@ -3889,7 +3889,7 @@ export type ArticleResolvers, ParentType, ContextType>; readingProgressAnchorIndex?: Resolver; readingProgressPercent?: Resolver; - recommendedBy?: Resolver>, ParentType, ContextType>; + recommendations?: Resolver>, ParentType, ContextType>; savedAt?: Resolver; savedByViewer?: Resolver, ParentType, ContextType>; shareInfo?: Resolver, ParentType, ContextType>; @@ -4851,7 +4851,7 @@ export type RecommendationResolvers; note?: Resolver, ParentType, ContextType>; recommendedAt?: Resolver; - user?: Resolver; + user?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5016,7 +5016,7 @@ export type SearchItemResolvers, ParentType, ContextType>; readingProgressAnchorIndex?: Resolver; readingProgressPercent?: Resolver; - recommendedBy?: Resolver>, ParentType, ContextType>; + recommendations?: Resolver>, ParentType, ContextType>; savedAt?: Resolver; shortId?: Resolver, ParentType, ContextType>; siteIcon?: Resolver, ParentType, ContextType>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 7178aa080..89f6cbf6b 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -86,7 +86,7 @@ type Article { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] savedAt: Date! savedByViewer: Boolean shareInfo: LinkShareInfo @@ -1265,7 +1265,7 @@ type Recommendation { name: String! note: String recommendedAt: Date! - user: RecommendingUser! + user: RecommendingUser } type RecommendationGroup { @@ -1498,7 +1498,7 @@ type SearchItem { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] savedAt: Date! shortId: String siteIcon: String diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 695e47664..6333aba8a 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -1015,6 +1015,7 @@ export const updatesSinceResolver = authorized< } }) + console.log('edges', JSON.stringify(edges)) return { edges, pageInfo: { diff --git a/packages/api/src/resolvers/recommendations/index.ts b/packages/api/src/resolvers/recommendations/index.ts index d86d45bde..d3e90df89 100644 --- a/packages/api/src/resolvers/recommendations/index.ts +++ b/packages/api/src/resolvers/recommendations/index.ts @@ -185,7 +185,8 @@ export const recommendResolver = authorized< member.user.id, page.id, { - ...group, + id: group.id, + name: group.name, note: input.note ?? null, user: { userId: user.id, diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index a281a70de..de1a182e3 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -384,7 +384,7 @@ const schema = gql` state: ArticleSavingRequestStatus language: String readAt: Date - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] } # Query: article @@ -1531,7 +1531,7 @@ const schema = gql` savedAt: Date! highlights: [Highlight!] siteIcon: String - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] } type SearchItemEdge { diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index ec53bce1a..cebf4c58b 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -167,7 +167,7 @@ "wordsCount": { "type": "integer" }, - "recommendedBy": { + "recommendations": { "type": "nested", "properties": { "id": { diff --git a/packages/db/migrate.ts b/packages/db/migrate.ts index 01b6ef065..767ac5efc 100755 --- a/packages/db/migrate.ts +++ b/packages/db/migrate.ts @@ -5,6 +5,7 @@ import chalk from 'chalk' import { Client } from '@elastic/elasticsearch' import { readFileSync } from 'fs' import { join } from 'path' +import { print } from 'graphql' dotenv.config() @@ -109,6 +110,86 @@ const createIndex = async (): Promise => { }) } +const reIndex = async (): Promise => { + const newPages = INDEX_NAME + '-002' + // // create index + // try { + // await esClient.indices.delete({ + // index: newPages, + // }) + // } catch (e) { + // console.log('001: ', e) + // } + + // try { + // await esClient.indices.delete({ + // index: 'pages-temp', + // }) + // } catch (e) { + // console.log('002: ', e) + // } + + // try { + // await esClient.indices.delete({ + // index: 'pages-temp-001', + // }) + // } catch (e) { + // console.log('003: ', e) + // } + + // try { + // await esClient.indices.delete({ + // index: 'pages-temp', + // }) + // } catch (e) { + // console.log('002: ', e) + // } + + try { + log('getting pages alias:') + const result = await esClient.indices.getAlias({ + index: 'pages_alias', + }) + log('result ' + JSON.stringify(result.body)) + } catch (err) { + log('fetch error: ' + err) + } + + log('creating newPages') + await esClient.indices.create({ + index: newPages, + body: JSON.parse(indexSettings), + }) + + log('reindexing') + await esClient.reindex({ + body: { + source: { index: INDEX_NAME }, + dest: { index: newPages }, + }, + wait_for_completion: true, + }) + + console.log('updateAliases') + + await esClient.indices.updateAliases({ + body: { + actions: [ + { + add: { index: newPages, alias: 'pages_alias', is_write_index: true }, + }, + ], + }, + }) + + console.log('updateAliases 002') + esClient.indices.updateAliases({ + body: { + actions: [{ remove: { index: INDEX_NAME, alias: 'pages_alias' } }], + }, + }) +} + const updateMappings = async (): Promise => { // update mappings await esClient.indices.putMapping({ @@ -131,59 +212,85 @@ const postgresMigration = postgrator process.exit(1) }) +// console.log('reindexing') +// reIndex() +// .then(() => console.log('reindexed elastic')) +// .catch((err) => console.log('error reindexing', err)) + // elastic migration log('Creating elastic index...') -const elasticMigration = esClient.indices - .exists({ index: INDEX_ALIAS }) - .then(({ body: exists }) => { - if (!exists) { - return createIndex().then(() => log('Elastic index created.')) - } else { - log('Elastic index already exists.') - } - }) - .then(() => { - log('Updating elastic index mappings...') - return updateMappings().then(() => { - log('Elastic index mappings updated.') +const indexTest = async (): Promise => { + log('running indexTest') + try { + const result = await esClient.indices.getAlias({ + index: 'pages_alias', }) - }) - .then(() => { - log('Adding default state to pages in elastic...') - return esClient - .update_by_query({ - index: INDEX_ALIAS, - requests_per_second: 250, - scroll_size: 500, - timeout: '30m', - body: { - script: { - source: 'ctx._source.state = params.state', - lang: 'painless', - params: { - state: 'SUCCEEDED', - }, - }, - query: { - bool: { - must_not: [ - { - exists: { - field: 'state', - }, - }, - ], - }, - }, - }, - }) - .then(() => log('Default state added.')) - }) - .catch((error) => { - log(`${chalk.red('Elastic migration failed: ')}${error.message}`, chalk.red) - const { appliedMigrations } = error - logAppliedMigrations(appliedMigrations) - process.exit(1) - }) + log('result ' + JSON.stringify(result.body)) + } catch (err) { + log('fetch error: ' + err) + } +} -Promise.all([postgresMigration, elasticMigration]).then(() => log('Exiting...')) +reIndex().then(() => console.log('ran reindex')) +// indexTest().then(() => console.log('ran index test')) + +// const elasticMigration = esClient.indices +// .exists({ index: INDEX_ALIAS }) +// .then(({ body: exists }) => { +// if (!exists) { +// return createIndex().then(() => log('Elastic index created.')) +// } else { +// log('Elastic index already exists.') +// } +// }) +// .then(() => { +// log('Updating elastic index mappings...') +// return updateMappings().then(() => { +// log('Elastic index mappings updated.') +// }) +// }) +// .then(() => { +// log('Adding default state to pages in elastic...') +// return esClient +// .update_by_query({ +// index: INDEX_ALIAS, +// requests_per_second: 250, +// scroll_size: 500, +// timeout: '30m', +// body: { +// script: { +// source: 'ctx._source.state = params.state', +// lang: 'painless', +// params: { +// state: 'SUCCEEDED', +// }, +// }, +// query: { +// bool: { +// must_not: [ +// { +// exists: { +// field: 'state', +// }, +// }, +// ], +// }, +// }, +// }, +// }) +// .then(() => log('Default state added.')) +// }) +// .catch((error) => { +// console.log('error', JSON.stringify(error)) +// log(`${chalk.red('Elastic migration failed: ')}${error.message}`, chalk.red) +// const { appliedMigrations } = error +// logAppliedMigrations(appliedMigrations) +// process.exit(1) +// }) + +Promise.all([ + // postgresMigration, + // indexTest, + // reIndex, + /* elasticMigration */ +]).then(() => log('Exiting...')) diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index 287ee1a43..be7893b41 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -82,7 +82,7 @@ export type LibraryItemNode = { siteName?: string subscription?: string readAt?: string - recommendedBy?: Recommendation[] + recommendations?: Recommendation[] } export type Recommendation = { @@ -151,7 +151,7 @@ export function useGetLibraryItemsQuery({ siteName subscription readAt - recommendedBy { + recommendations { id name note From 533a4ae65c80d68466c43cf835cdd7f914375064 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 6 Dec 2022 21:23:19 +0800 Subject: [PATCH 18/20] Update recommendedBy query --- packages/api/src/elastic/pages.ts | 4 ++-- packages/api/src/elastic/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 8cb48fbbf..bce0dba98 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -189,10 +189,10 @@ const appendIdsFilter = (body: SearchBody, ids: string[]): void => { const appendRecommendedBy = (body: SearchBody, recommendedBy: string): void => { body.query.bool.must.push({ nested: { - path: 'recommendedBy', + path: 'recommendations', query: { term: { - 'recommendedBy.name': recommendedBy, + 'recommendations.name': recommendedBy, }, }, }, diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index e4164b9c3..9b7122e57 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -53,10 +53,10 @@ export interface SearchBody { } | { nested: { - path: 'recommendedBy' + path: 'recommendations' query: { term: { - 'recommendedBy.name': string + 'recommendations.name': string } } } From 1dafdae7cc4158d8bb17e5757ffd0dd4a5de690b Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 7 Dec 2022 11:22:36 +0800 Subject: [PATCH 19/20] Revert changes to migrate script --- packages/db/migrate.ts | 213 ++++++++++------------------------------- 1 file changed, 53 insertions(+), 160 deletions(-) diff --git a/packages/db/migrate.ts b/packages/db/migrate.ts index 767ac5efc..8a4386940 100755 --- a/packages/db/migrate.ts +++ b/packages/db/migrate.ts @@ -5,7 +5,6 @@ import chalk from 'chalk' import { Client } from '@elastic/elasticsearch' import { readFileSync } from 'fs' import { join } from 'path' -import { print } from 'graphql' dotenv.config() @@ -71,7 +70,7 @@ log(`Migrating to ${targetMigrationLabel}.\n`) const logAppliedMigrations = ( appliedMigrations: Postgrator.Migration[] ): void => { - if (appliedMigrations && appliedMigrations.length > 0) { + if (appliedMigrations.length > 0) { log( `Applied ${chalk.green( appliedMigrations.length.toString() @@ -110,86 +109,6 @@ const createIndex = async (): Promise => { }) } -const reIndex = async (): Promise => { - const newPages = INDEX_NAME + '-002' - // // create index - // try { - // await esClient.indices.delete({ - // index: newPages, - // }) - // } catch (e) { - // console.log('001: ', e) - // } - - // try { - // await esClient.indices.delete({ - // index: 'pages-temp', - // }) - // } catch (e) { - // console.log('002: ', e) - // } - - // try { - // await esClient.indices.delete({ - // index: 'pages-temp-001', - // }) - // } catch (e) { - // console.log('003: ', e) - // } - - // try { - // await esClient.indices.delete({ - // index: 'pages-temp', - // }) - // } catch (e) { - // console.log('002: ', e) - // } - - try { - log('getting pages alias:') - const result = await esClient.indices.getAlias({ - index: 'pages_alias', - }) - log('result ' + JSON.stringify(result.body)) - } catch (err) { - log('fetch error: ' + err) - } - - log('creating newPages') - await esClient.indices.create({ - index: newPages, - body: JSON.parse(indexSettings), - }) - - log('reindexing') - await esClient.reindex({ - body: { - source: { index: INDEX_NAME }, - dest: { index: newPages }, - }, - wait_for_completion: true, - }) - - console.log('updateAliases') - - await esClient.indices.updateAliases({ - body: { - actions: [ - { - add: { index: newPages, alias: 'pages_alias', is_write_index: true }, - }, - ], - }, - }) - - console.log('updateAliases 002') - esClient.indices.updateAliases({ - body: { - actions: [{ remove: { index: INDEX_NAME, alias: 'pages_alias' } }], - }, - }) -} - const updateMappings = async (): Promise => { // update mappings await esClient.indices.putMapping({ @@ -212,85 +131,59 @@ const postgresMigration = postgrator process.exit(1) }) -// console.log('reindexing') -// reIndex() -// .then(() => console.log('reindexed elastic')) -// .catch((err) => console.log('error reindexing', err)) - // elastic migration log('Creating elastic index...') -const indexTest = async (): Promise => { - log('running indexTest') - try { - const result = await esClient.indices.getAlias({ - index: 'pages_alias', +const elasticMigration = esClient.indices + .exists({ index: INDEX_ALIAS }) + .then(({ body: exists }) => { + if (!exists) { + return createIndex().then(() => log('Elastic index created.')) + } else { + log('Elastic index already exists.') + } + }) + .then(() => { + log('Updating elastic index mappings...') + return updateMappings().then(() => { + log('Elastic index mappings updated.') }) - log('result ' + JSON.stringify(result.body)) - } catch (err) { - log('fetch error: ' + err) - } -} + }) + .then(() => { + log('Adding default state to pages in elastic...') + return esClient + .update_by_query({ + index: INDEX_ALIAS, + requests_per_second: 250, + scroll_size: 500, + timeout: '30m', + body: { + script: { + source: 'ctx._source.state = params.state', + lang: 'painless', + params: { + state: 'SUCCEEDED', + }, + }, + query: { + bool: { + must_not: [ + { + exists: { + field: 'state', + }, + }, + ], + }, + }, + }, + }) + .then(() => log('Default state added.')) + }) + .catch((error) => { + log(`${chalk.red('Elastic migration failed: ')}${error.message}`, chalk.red) + const { appliedMigrations } = error + logAppliedMigrations(appliedMigrations) + process.exit(1) + }) -reIndex().then(() => console.log('ran reindex')) -// indexTest().then(() => console.log('ran index test')) - -// const elasticMigration = esClient.indices -// .exists({ index: INDEX_ALIAS }) -// .then(({ body: exists }) => { -// if (!exists) { -// return createIndex().then(() => log('Elastic index created.')) -// } else { -// log('Elastic index already exists.') -// } -// }) -// .then(() => { -// log('Updating elastic index mappings...') -// return updateMappings().then(() => { -// log('Elastic index mappings updated.') -// }) -// }) -// .then(() => { -// log('Adding default state to pages in elastic...') -// return esClient -// .update_by_query({ -// index: INDEX_ALIAS, -// requests_per_second: 250, -// scroll_size: 500, -// timeout: '30m', -// body: { -// script: { -// source: 'ctx._source.state = params.state', -// lang: 'painless', -// params: { -// state: 'SUCCEEDED', -// }, -// }, -// query: { -// bool: { -// must_not: [ -// { -// exists: { -// field: 'state', -// }, -// }, -// ], -// }, -// }, -// }, -// }) -// .then(() => log('Default state added.')) -// }) -// .catch((error) => { -// console.log('error', JSON.stringify(error)) -// log(`${chalk.red('Elastic migration failed: ')}${error.message}`, chalk.red) -// const { appliedMigrations } = error -// logAppliedMigrations(appliedMigrations) -// process.exit(1) -// }) - -Promise.all([ - // postgresMigration, - // indexTest, - // reIndex, - /* elasticMigration */ -]).then(() => log('Exiting...')) +Promise.all([postgresMigration, elasticMigration]).then(() => log('Exiting...')) From b150083358371d80d07124f6c2331e2ae55d2ee8 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 7 Dec 2022 11:53:48 +0800 Subject: [PATCH 20/20] Remove debug --- packages/api/src/resolvers/article/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 6333aba8a..695e47664 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -1015,7 +1015,6 @@ export const updatesSinceResolver = authorized< } }) - console.log('edges', JSON.stringify(edges)) return { edges, pageInfo: {