From 02bac4af2bb212589f995d2023c2155a15b22b88 Mon Sep 17 00:00:00 2001 From: Rachit Shah Date: Sat, 18 May 2024 16:36:32 -0700 Subject: [PATCH] feat: Upload webpage with readability and other UX improvements --- AetherVoice.xcodeproj/project.pbxproj | 47 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 39 ++++++++++- AetherVoice/CoreData/Persistence.swift | 16 +++++ AetherVoice/Models/AppDocument.swift | 2 +- AetherVoice/Sheets/URLInputSheet.swift | 70 +++++++++++++++++++ .../ViewModels/DocumentListViewModel.swift | 53 +++++++++++++- AetherVoice/Views/ContentView.swift | 30 +++++--- AetherVoice/Views/DocumentListView.swift | 25 ++++++- Podfile | 11 +++ 9 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 AetherVoice/Sheets/URLInputSheet.swift create mode 100644 Podfile diff --git a/AetherVoice.xcodeproj/project.pbxproj b/AetherVoice.xcodeproj/project.pbxproj index 43ed404..9504263 100644 --- a/AetherVoice.xcodeproj/project.pbxproj +++ b/AetherVoice.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 6036DBE82BF95CFD00A42D1A /* Readability in Frameworks */ = {isa = PBXBuildFile; productRef = 6036DBE72BF95CFD00A42D1A /* Readability */; }; 6040521C2B40414E00100DC5 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 6040521B2B40414E00100DC5 /* README.md */; }; 604052202B405DC700100DC5 /* AWSSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6040521F2B405DC700100DC5 /* AWSSettingsView.swift */; }; 604052222B405DF200100DC5 /* GCPSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604052212B405DF200100DC5 /* GCPSettingsView.swift */; }; @@ -18,6 +19,7 @@ 604A91482B3CB36200836DAF /* RuntimeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604A91472B3CB36200836DAF /* RuntimeError.swift */; }; 605FC1B52BE72C030086987A /* AzureSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605FC1B42BE72C030086987A /* AzureSettingsView.swift */; }; 605FC1B72BE73D890086987A /* AzureVoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605FC1B62BE73D890086987A /* AzureVoice.swift */; }; + 606D3B9D2BF943680047994C /* URLInputSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606D3B9C2BF943680047994C /* URLInputSheet.swift */; }; 607283C82B3875D7002B5DAF /* AetherVoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607283C72B3875D7002B5DAF /* AetherVoiceApp.swift */; }; 607283CA2B3875D7002B5DAF /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607283C92B3875D7002B5DAF /* Persistence.swift */; }; 607283CD2B3875D7002B5DAF /* AetherVoice.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 607283CB2B3875D7002B5DAF /* AetherVoice.xcdatamodeld */; }; @@ -54,6 +56,7 @@ 604A91472B3CB36200836DAF /* RuntimeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeError.swift; sourceTree = ""; }; 605FC1B42BE72C030086987A /* AzureSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureSettingsView.swift; sourceTree = ""; }; 605FC1B62BE73D890086987A /* AzureVoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureVoice.swift; sourceTree = ""; }; + 606D3B9C2BF943680047994C /* URLInputSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLInputSheet.swift; sourceTree = ""; }; 607283C42B3875D7002B5DAF /* AetherVoice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AetherVoice.app; sourceTree = BUILT_PRODUCTS_DIR; }; 607283C72B3875D7002B5DAF /* AetherVoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AetherVoiceApp.swift; sourceTree = ""; }; 607283C92B3875D7002B5DAF /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; @@ -92,6 +95,7 @@ 604A8F322B3C563F00836DAF /* AWSCognitoIdentity in Frameworks */, 604052A82B40892B00100DC5 /* GoogleAPIClientForREST_Texttospeech in Frameworks */, 607283E42B39B2A5002B5DAF /* UniformTypeIdentifiers.framework in Frameworks */, + 6036DBE82BF95CFD00A42D1A /* Readability in Frameworks */, 604052AE2B42EF8400100DC5 /* AppKit.framework in Frameworks */, 604A90842B3C564700836DAF /* AWSPolly in Frameworks */, 607283E02B39B074002B5DAF /* MobileCoreServices.framework in Frameworks */, @@ -101,6 +105,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 606D3B9B2BF943540047994C /* Sheets */ = { + isa = PBXGroup; + children = ( + 606D3B9C2BF943680047994C /* URLInputSheet.swift */, + ); + path = Sheets; + sourceTree = ""; + }; 607283BB2B3875D7002B5DAF = { isa = PBXGroup; children = ( @@ -127,6 +139,7 @@ 607283FC2B3A0CE8002B5DAF /* Utilities */, 607283E92B39BE90002B5DAF /* CoreData */, 607283E82B39BE8C002B5DAF /* Services */, + 606D3B9B2BF943540047994C /* Sheets */, 607283E72B39BE84002B5DAF /* ViewModels */, 607283E62B39BE76002B5DAF /* Views */, 607283E52B39BE67002B5DAF /* Models */, @@ -251,6 +264,7 @@ 604A8F312B3C563F00836DAF /* AWSCognitoIdentity */, 604A90832B3C564700836DAF /* AWSPolly */, 604052A72B40892B00100DC5 /* GoogleAPIClientForREST_Texttospeech */, + 6036DBE72BF95CFD00A42D1A /* Readability */, ); productName = CloudReader; productReference = 607283C42B3875D7002B5DAF /* AetherVoice.app */; @@ -284,6 +298,9 @@ 604A8E922B3C563D00836DAF /* XCRemoteSwiftPackageReference "aws-sdk-swift" */, 604052232B4085DF00100DC5 /* XCRemoteSwiftPackageReference "google-api-swift-client" */, 604052282B4088AF00100DC5 /* XCRemoteSwiftPackageReference "google-api-objectivec-client-for-rest" */, + 6036DBE02BF9552600A42D1A /* XCRemoteSwiftPackageReference "SwiftSoup" */, + 6036DBE32BF958BC00A42D1A /* XCRemoteSwiftPackageReference "ReadabilityKit" */, + 6036DBE62BF95CFD00A42D1A /* XCRemoteSwiftPackageReference "read-swift" */, ); productRefGroup = 607283C52B3875D7002B5DAF /* Products */; projectDirPath = ""; @@ -316,6 +333,7 @@ files = ( 607283EF2B39BEDE002B5DAF /* DocumentReaderView.swift in Sources */, 604052AA2B42DFB400100DC5 /* SplashView.swift in Sources */, + 606D3B9D2BF943680047994C /* URLInputSheet.swift in Sources */, 6072840A2B3AE68A002B5DAF /* AmazonPollySynthesizer.swift in Sources */, 607283C82B3875D7002B5DAF /* AetherVoiceApp.swift in Sources */, 60DFF5E92B3EF375004E098C /* KeychainWrapper.swift in Sources */, @@ -577,6 +595,30 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 6036DBE02BF9552600A42D1A /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.7.2; + }; + }; + 6036DBE32BF958BC00A42D1A /* XCRemoteSwiftPackageReference "ReadabilityKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/ReadabilityKit.git"; + requirement = { + branch = master; + kind = branch; + }; + }; + 6036DBE62BF95CFD00A42D1A /* XCRemoteSwiftPackageReference "read-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ttscoff/read-swift.git"; + requirement = { + branch = master; + kind = branch; + }; + }; 604052232B4085DF00100DC5 /* XCRemoteSwiftPackageReference "google-api-swift-client" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/googleapis/google-api-swift-client.git"; @@ -604,6 +646,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 6036DBE72BF95CFD00A42D1A /* Readability */ = { + isa = XCSwiftPackageProductDependency; + package = 6036DBE62BF95CFD00A42D1A /* XCRemoteSwiftPackageReference "read-swift" */; + productName = Readability; + }; 604052A72B40892B00100DC5 /* GoogleAPIClientForREST_Texttospeech */ = { isa = XCSwiftPackageProductDependency; package = 604052282B4088AF00100DC5 /* XCRemoteSwiftPackageReference "google-api-objectivec-client-for-rest" */; diff --git a/AetherVoice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AetherVoice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 76e8eec..429ff20 100644 --- a/AetherVoice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AetherVoice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "e1480913fee0377ac9ce9e98475affed68b16b8984e458867e554ba3f77d28e0", "pins" : [ { "identity" : "aws-crt-swift", @@ -72,6 +73,33 @@ "version" : "3.2.0" } }, + { + "identity" : "ji", + "kind" : "remoteSourceControl", + "location" : "https://github.com/honghaoz/Ji", + "state" : { + "revision" : "a37a310cc6aaf999de4bee953a9680bf9b629200", + "version" : "5.1.0" + } + }, + { + "identity" : "read-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ttscoff/read-swift.git", + "state" : { + "branch" : "master", + "revision" : "55db904566502acac7fbe3fd9fe4c7f416a49afe" + } + }, + { + "identity" : "readabilitykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/ReadabilityKit.git", + "state" : { + "branch" : "master", + "revision" : "bab3bf8f62f08af2bfc78e0c4f7c9e6c6ef51a11" + } + }, { "identity" : "smithy-swift", "kind" : "remoteSourceControl", @@ -117,6 +145,15 @@ "version" : "2.62.0" } }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "028487d4a8a291b2fe1b4392b5425b6172056148", + "version" : "2.7.2" + } + }, { "identity" : "xmlcoder", "kind" : "remoteSourceControl", @@ -127,5 +164,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/AetherVoice/CoreData/Persistence.swift b/AetherVoice/CoreData/Persistence.swift index c3caf61..0d4754c 100644 --- a/AetherVoice/CoreData/Persistence.swift +++ b/AetherVoice/CoreData/Persistence.swift @@ -46,6 +46,22 @@ class PersistenceController { print("Failed to save document: \(error)") } } + + func deleteDocument(_ appDocument: AppDocument) { + let fetchRequest: NSFetchRequest = Document.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %@", appDocument.id as CVarArg) + + do { + let documentEntities = try container.viewContext.fetch(fetchRequest) + for document in documentEntities { + container.viewContext.delete(document) + } + try container.viewContext.save() + } catch { + // Handle the error appropriately + print("Failed to delete document: \(error)") + } + } func fetchDocuments() -> [AppDocument] { let fetchRequest: NSFetchRequest = Document.fetchRequest() diff --git a/AetherVoice/Models/AppDocument.swift b/AetherVoice/Models/AppDocument.swift index cd61002..883af25 100644 --- a/AetherVoice/Models/AppDocument.swift +++ b/AetherVoice/Models/AppDocument.swift @@ -1,6 +1,6 @@ import Foundation -struct AppDocument: Identifiable { +struct AppDocument: Hashable, Identifiable { var id: UUID var title: String var content: String diff --git a/AetherVoice/Sheets/URLInputSheet.swift b/AetherVoice/Sheets/URLInputSheet.swift new file mode 100644 index 0000000..80818a3 --- /dev/null +++ b/AetherVoice/Sheets/URLInputSheet.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct URLInputSheet: View { + @Binding var isPresented: Bool + @State private var urlStrings: [String] = [""] + var fetchContent: ([String]) -> Void + + var body: some View { + NavigationView { + VStack(spacing: 20) { + ScrollView { + ForEach(0.. 1 { + Button(action: { + urlStrings.remove(at: index) + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + } + } + .padding(.vertical, 5) + } + } + + HStack(spacing: 20) { + Button(action: { + urlStrings.append("") + }) { + Image(systemName: "plus.circle.fill") + .padding(.horizontal, 16) + } + .background(Color.green) + .foregroundColor(.white) + .clipShape(Capsule()) + + Button(action: { + fetchContent(urlStrings.filter { !$0.isEmpty }) + isPresented = false + }) { + Image(systemName: "checkmark.circle.fill") + .padding(.horizontal, 16) + } + .background(Color.blue) + .foregroundColor(.white) + .clipShape(Capsule()) + } + .padding(.horizontal) + + Spacer() + } + .padding() + .navigationTitle("Enter URLs") + #if os(iOS) + .navigationBarItems(trailing: Button(action: { + isPresented = false + }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + }) + #endif + } + } +} diff --git a/AetherVoice/ViewModels/DocumentListViewModel.swift b/AetherVoice/ViewModels/DocumentListViewModel.swift index 903f76e..df2e57a 100644 --- a/AetherVoice/ViewModels/DocumentListViewModel.swift +++ b/AetherVoice/ViewModels/DocumentListViewModel.swift @@ -1,9 +1,10 @@ import Combine import Foundation import PDFKit +import Readability import UniformTypeIdentifiers -class DocumentListViewModel: ObservableObject { +@MainActor class DocumentListViewModel: ObservableObject { @Published var documents: [AppDocument] = [] var synthesizerDict: [TTSService: SpeechSynthesizerProtocol] = [TTSService: SpeechSynthesizerProtocol]() private let persistenceController = PersistenceController.shared @@ -17,6 +18,18 @@ class DocumentListViewModel: ObservableObject { persistenceController.saveDocument(appDocument) fetchDocuments() // Refresh the documents list } + + func deleteDocument(_ document: AppDocument) { + persistenceController.deleteDocument(document) + fetchDocuments() // Refresh the document list after deletion + } + + func deleteDocuments(at offsets: IndexSet) { + for index in offsets { + let document = documents[index] + deleteDocument(document) + } + } func fetchDocuments() { documents = persistenceController.fetchDocuments() @@ -104,6 +117,44 @@ class DocumentListViewModel: ObservableObject { print("Unsupported file type") } } + + func fetchContent(at urlString: String) { + print("URL: \(urlString)") + guard let url = URL(string: urlString) else { + print("Invalid url \(urlString)") + return + } + + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + // Handle error + print("Error fetching content: \(error)") + return + } + + guard let data = data, let html = String(data: data, encoding: .utf8) else { + print("No data returned from url \(urlString)") + return + } + + do { + // Parse the webpage content + let readability = Readability(html: html) + let started = readability.start() + if started { + let title = try readability.articleTitle?.text() + let data = try readability.articleContent?.text() + if (title != nil && data != nil) { + let newDocument = AppDocument(title: title!, content: data!) + self.saveDocument(newDocument) + } + } + } catch { + print("Error parsing html \(html)") + } + } + task.resume() + } func processPlainTextDocument(at url: URL) { if let contents = try? String(contentsOf: url) { diff --git a/AetherVoice/Views/ContentView.swift b/AetherVoice/Views/ContentView.swift index a22892d..4f5d95f 100644 --- a/AetherVoice/Views/ContentView.swift +++ b/AetherVoice/Views/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @ObservedObject var viewModel: DocumentListViewModel @State private var showingSettings = false + @State private var showingURLInputSheet = false #if os(iOS) @State private var showingActionSheet = false @State private var showingDocumentPicker = false @@ -24,6 +25,9 @@ struct ContentView: View { } #elseif os(macOS) .toolbar { + ToolbarItemGroup(placement: .automatic) { + Spacer() + } ToolbarItemGroup(placement: .automatic) { Button(action: { self.showingSettings.toggle() @@ -36,6 +40,13 @@ struct ContentView: View { .padding() } addButtonmacOS + .popover(isPresented: $showingURLInputSheet) { + URLInputSheet(isPresented: $showingURLInputSheet) { urlStrings in + urlStrings.forEach { urlString in + viewModel.fetchContent(at: urlString) + } + } + } } } #endif @@ -61,19 +72,26 @@ struct ContentView: View { showingDocumentPicker = true }, .default(Text("Link a Webpage")) { - // Implement webpage linking functionality + showingURLInputSheet = true }, .cancel() ]) } .sheet(isPresented: $showingDocumentPicker) { DocumentPicker(allowedContentTypes: [.plainText, .pdf, .epub]) { urls in - urls?.forEach { url in + urls.forEach { url in // Handle the picked document URL viewModel.processDocument(at: url) } } } + .sheet(isPresented: $showingURLInputSheet) { + URLInputSheet(isPresented: $showingURLInputSheet) { urlStrings in + urlStrings.forEach { urlString in + viewModel.fetchContent(at: urlString) + } + } + } } #endif @@ -84,15 +102,9 @@ struct ContentView: View { viewModel.uploadDocument() } Button("Link a Webpage") { - // Implement webpage linking functionality + showingURLInputSheet = true } } } #endif - - #if os(iOS) - func uploadDocument() { - showingDocumentPicker = true - } - #endif } diff --git a/AetherVoice/Views/DocumentListView.swift b/AetherVoice/Views/DocumentListView.swift index 696bf47..26ee476 100644 --- a/AetherVoice/Views/DocumentListView.swift +++ b/AetherVoice/Views/DocumentListView.swift @@ -4,14 +4,33 @@ struct DocumentListView: View { @ObservedObject var viewModel: DocumentListViewModel var body: some View { - List(viewModel.documents) { document in - NavigationLink(destination: DocumentReaderView(viewModel: DocumentReaderViewModel(document: document, synthesizerDict: viewModel.synthesizerDict))) { - Text(document.title) + List { + ForEach(viewModel.documents) { document in + NavigationLink(destination: DocumentReaderView(viewModel: DocumentReaderViewModel(document: document, synthesizerDict: viewModel.synthesizerDict))) { + Text(document.title) + } + #if os(macOS) + .contextMenu { + Button(action: { + viewModel.deleteDocument(document) + }) { + Text("Delete") + Image(systemName: "trash") + } + } + #endif } + #if os(iOS) + .onDelete(perform: deleteDocuments) + #endif } .navigationTitle("Documents") .onAppear { viewModel.fetchDocuments() } } + + private func deleteDocuments(at offsets: IndexSet) { + viewModel.deleteDocuments(at: offsets) + } } diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..b7932f0 --- /dev/null +++ b/Podfile @@ -0,0 +1,11 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'AetherVoice' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for AetherVoice + pod 'SwiftReadability', '~> 1.0.1' + +end