From f8a6fc9e0d6322d7d4487a2ece17c740054f7a8a Mon Sep 17 00:00:00 2001 From: Lily Mara Date: Fri, 27 Oct 2023 08:44:26 -0700 Subject: [PATCH 01/64] Improve developer environment onboarding I encountered a number of issues while trying to get my own local dev environment set up. This change adds missing information to the readme, updates the Postgres image to one that includes required module dependencies, and removes next.js rewrite rules (IS THIS SAFE?) which were no longer used and causing issues. --- README.md | 17 ++++++++++++++--- docker-compose.yml | 2 +- packages/web/next.config.js | 22 ---------------------- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 111cdb065..eb30a36ad 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/omnivore-app/omnivore/Run%20tests)](https://github.com/omnivore-app/omnivore/actions/workflows/run-tests.yaml) [![Discord](https://img.shields.io/discord/844965259462311966?label=Join%20our%20Discord)](https://discord.gg/h2z5rppzz9) -[![Mastodon Follow](https://img.shields.io/mastodon/follow/109458738600914558?domain=https%3A%2F%2Fpkm.social)](https://pkm.social/@omnivore) +[![Mastodon Follow](https://img.shields.io/mastodon/follow/109458738600914558?domain=https%3A%2F%2Fpkm.social)](https://pkm.social/@omnivore) [![Twitter Follow](https://img.shields.io/twitter/follow/omnivoreapp)](https://twitter.com/OmnivoreApp) ![GitHub](https://img.shields.io/github/license/omnivore-app/omnivore) @@ -34,7 +34,6 @@ We also have a free hosted version of Omnivore at [omnivore.app](https://omnivor web-screenshot-listview - ## Join us on Discord! :speech_balloon: We're building our community on Discord. [Join us!](https://discord.gg/h2z5rppzz9) @@ -100,10 +99,22 @@ with docker compose and the frontend locally: ```bash docker compose up api content-fetch cd packages/web -cp .env.template .env +cp .env.template .env.local yarn dev ``` +You will need to configure some values in the new `.env.local` file. These are +the values for running the `web` service directly on your host machine and +running `api` and `content-fetch` within docker: + +```sh +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_HIGHLIGHTS_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_LOCAL_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_SERVER_BASE_URL=http://localhost:4000 +NEXT_PUBLIC_LOCAL_SERVER_BASE_URL=http://localhost:4000 +``` + ### Running the puppeteer-parse service outside of Docker To save pages you need to run the `puppeteer-parse` service. diff --git a/docker-compose.yml b/docker-compose.yml index a4f8bcda6..0c5db1e06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: postgres: - image: "postgres:12.8" + image: "ankane/pgvector:v0.5.1" container_name: "omnivore-postgres" environment: - POSTGRES_USER=postgres diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 6e5012480..aa282657b 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -24,28 +24,6 @@ const moduleExports = { 'proxy.omnivore-image-cache.app', ], }, - rewrites: () => [ - { - source: '/api/graphql', - destination: `https://api-${process.env.NEXT_PUBLIC_APP_ENV}.omnivore.app/api/graphql`, - }, - { - source: '/api/auth/:path*', - destination: `https://api-${process.env.NEXT_PUBLIC_APP_ENV}.omnivore.app/api/auth/:path*`, - }, - { - source: '/api/article/save', - destination: `https://api-${process.env.NEXT_PUBLIC_APP_ENV}.omnivore.app/api/article/save`, - }, - { - source: '/api/mobile-auth/:path*', - destination: `https://api-${process.env.NEXT_PUBLIC_APP_ENV}.omnivore.app/api/mobile-auth/:path*`, - }, - { - source: '/collect/:match*', - destination: 'https://app.posthog.com/:match*', - }, - ], async headers() { return [ { From a840d79502e218a3266cc2e8a8f105686c4aa3b3 Mon Sep 17 00:00:00 2001 From: Harshavardhan Bajoria <62978274+HVbajoria@users.noreply.github.com> Date: Sat, 28 Oct 2023 14:39:56 +0530 Subject: [PATCH 02/64] Create getting-started-guide.md in japanese --- .../ja/getting-started-guide.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/guides/getting-started/ja/getting-started-guide.md diff --git a/docs/guides/getting-started/ja/getting-started-guide.md b/docs/guides/getting-started/ja/getting-started-guide.md new file mode 100644 index 000000000..db037e6a6 --- /dev/null +++ b/docs/guides/getting-started/ja/getting-started-guide.md @@ -0,0 +1,160 @@ +# Omnivoreをはじめよう + +Omnivoreは、オンラインで読んだものを保存し整理できる **後で読むアプリ** です。 + +このガイドでは、Omnivoreの基本的な機能と高度な機能の使い方を紹介し、次の4つの主要な活動に分けて説明します: + +- 保存 +- 読む +- 整理 +- 統合 + +**ライブラリ** は、Omnivoreのエクスペリエンスの中心であり、保存したリンクにすばやくアクセスできます。保存したリンクは削除しない限り、ライブラリに永久に残ります。 + +## 保存 + +後で読むためにページや記事へのリンクを保存する主要な方法は次の5つです: + +- Omnivoreライブラリから保存 +- ブラウザから保存 +- スマートフォンまたはタブレット(iOSまたはAndroid)から保存 +- メール経由のニュースレター購読 +- MacからPDFを保存 + +### Omnivoreライブラリから保存 + +1. ライブラリの右上隅にある **リンクを追加** ボタンをタップします。 +2. 保存したいURLを入力し、 **リンクを追加** をタップします。 +3. リンクは、次にライブラリをリフレッシュすると表示されます。 + +### ブラウザからの保存 + +1. ブラウザ用のOmnivore拡張機能をダウンロードしてインストールします: + +- [Chrome](https://omnivore.app/install/chrome) +- [Edge](https://omnivore.app/install/edge) +- [Firefox](https://omnivore.app/install/firefox) +- [Safari](https://omnivore.app/install/safari) + +2. 保存したいページに移動し、ブラウザのツールバーまたは拡張機能メニューにあるOmnivoreボタンをタップします。 +3. または、リンクを右クリック(Macの場合はコマンド+クリック)し、メニューから**Omnivoreに保存**を選択できます。 +4. リンクは、次にライブラリをリフレッシュすると表示されます。 + +### スマートフォンまたはタブレットからの保存 + +モバイルデバイスからリンクを保存する最良の方法は、Omnivoreアプリを使用することです。アプリはこちらからダウンロードできます: + +- [iOS(iPhoneまたはiPad)](https://omnivore.app/install/ios) +- Android + +モバイルアプリをインストールしたら: + +1. ブラウザで保存したいページに移動し、**共有**ボタンをタップします。 +2. 共有メニューで**Omnivore**アイコンをタップします。 +3. リンクは、次にライブラリをリフレッシュすると表示されます。 + +### メール経由のニュースレター購読 + +1. Omnivoreのウェブサイトまたアプリで、右上隅にある写真、イニシャル、またはアバターをタップしてプロファイルメニューにアクセスします。メニューから**Eメール**を選択します。 +2. **新しいメールアドレスを作成**をタップして、リストに新しいメールアドレス(例: username-123_abc@inbox.omnivore.app)を追加します。 +3. メールアドレスの横にあるコピーのアイコンをクリックします。 +4. 購読したいニュースレターの登録ページに移動します。 +5. Omnivoreのメールアドレスを登録フォームに貼り付けます。 +6. 新しいニュースレターは自動的にOmnivoreの受信トレイに配信されます。 + +### MacからPDFを保存 + +1. [Macアプリ](https://omnivore.app/install/mac)をインストールします。 +2. Mac上で保存したいPDFを見つけ、ファイル名を右クリックまたはCtrl+クリックします。 +3. メニューから**共有**を選択し、**Omnivore**を選びます。 +4. リンクは、次にライブラリをリフレッシュすると表示されます。 + +## 読む + +ライブラリに保存されたリンクをクリックしてリーダービューに入ります。 + +Omnivoreは、広告や雑然としたものを取り除き、気を散らさない読書をサポートするためにページをフォーマットします。テキストに焦点を当てたビューは、記事を小さくし、読み込みを迅速化します。 + +読書中に、次のことができます: +- フォーマットを変更 +- テキストをハイライト +- メモを追加 +- すべての保存されたハイライトとメモを表示 +- 読書の進行状況を追跡 + +### フォーマットの変更 + +1. **_テーマ:_** 右上隅にある写真、イニシャル、またはアバターをタップしてプロファイルメニューにアクセスします。白または黒のサムネイルを選択してライトまたはダークテーマを選択します。 +2. **_テキストフォーマット:_** Aaアイコンをタップして、テキストのサイズ、フォント、余白、行間を調整します。 + +### テキストをハイライト + +1. ハイライトしたいテキストを選択します。 +2. **ハイライト** ボタンをタップします。 +3. テキストは、記事を次に表示したときにハイライト表示されます。 + +### メモの追加 + +1. メモを追加したいテキストの一部をハイライトします。 +2. **メモ** ボタンをタップし、メモを入力し、**保存** ボタンをタップします。 +3. 次にこの記事を表示したときに、メモアイコンが表示されます。 + +### 保存されたハイライトとメモをすべて表示 + +1. ハイライト/メモアイコンをタップして、このページに追加したすべてのハイライトテキストとメモのリストを表示します。 +2. メモまたはハイライトを削除するには、リストから選択し、ゴミ箱アイコンをタップします。 + +### 読書の進行状況を追跡 + +Omnivoreは異なるデバイス間での読書の進行状況を自動的に追跡し、前回終了した場所から簡単に再開できるようにします。読書を開始した後、ライブラリ内の各リンクの上部に進行バーが表示されます。 + +## 整理 + +デフォルトでは、ライブラリの受信トレイには保存したすべてのリンクが表示されます。リストを管理し、読書を整理するために、Omnivoreは以下のアクションを提供します: + +- アーカイブ +- ラベル +- 検索 +- フィルタ + +### アーカイブ + +1. アーカイブしたいリンクの隣にあるメニューアイコンをタップします(モバイルアプリでは、リンクを長押ししてメニューを開きます)。 +2. **アーカイブ** を選択します。 +3. リンクはデフォルトのライブラリ表示から消えますが、アーカイブされたフィルタを選択すると表示されます(詳細は以下の フィルタ を参照)。 + +### ラベル + +1. 任意のリンクの隣にあるメニューアイコンをタップし、**ラベルの設定** を選択します。 +2. リストから既存のラベルを選択するか、新しいラベルを作成するには **ラベルの編集** をタップします。 +3. ラベルはライブラリ内のリンクの隣に表示されます。ラベルをタップして、同じラベルを持つすべてのリンクを表示できます。 +4. _Omnivoreモバイルアプリのみ_: **ラベル** をタップして、使用したすべてのラベルの完全なリストを表示します。ラベルをタップして、同じラベルを持つすべてのリンクを表示できます。 +5. 注意: Omnivoreは「ニュースレター」など、一部のラベルを自動的に割り当てます。 + +### 検索 + +1. 保存したすべてのリンクを検索するには、検索バーにキーワードやフレーズを入力します。 +2. キーワードをラベルやフィルタと組み合わせて、さらに絞り込んだ検索を行うことができます。[詳細な検索について詳しく](https://docs.omnivore.app/using/search.html)。 + +### フィルタ + +1. ライブラリ表示を絞り込むために **フィルタ** メニューを使用します(一部のフィルタはデフォルトで表示される場合があります)。 +2. **後で読む** を選択すると、アーカイブされていないリンクのリストが表示されます(ニュースレターを除く)。 +3. **ハイライト** を選択すると、保存したすべてのページでハイライトされたテキストが表示されます。 +4. **今日** を選択すると、今日保存したリンクのリストが表示されます。 +5. **ニュースレター** を選択すると、ニュースレターの購読を通じて保存したリンクが表示されます。 + +## 統合 + +Omnivoreは、ナレッジベースやノートアプリとの統合を許可しており、次のものが含まれています: + +- Logseq +- Webhooks + +### Logseq + +OmnivoreのLogseqプラグインを使用すると、保存した記事、ハイライト、メモをすべてLogseqに同期させることができます。Logseqは人気のあるナレッジベースです。Logseqプラグインの設定と使用に関する情報については、この有用な [Omnivore for Logseq Plugin Guide](https://briansunter.com/graph/#/page/omnivore-logseq-guide) を参照してください。 + +### Webhooks + +Omnivoreは、リンクを保存するか、読んでいるページにハイライトを追加するときにWebhooksをトリガーできます。 この例 では、Webhooksを使用してすべての保存されたリンクをGoogle Driveに保存されたGoogle Sheetsスプレッドシートに書き込む方法が示されています。 From cf3427fdc9542f8a6b463c1792523f5106dc74f1 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 26 Oct 2023 10:53:25 +0800 Subject: [PATCH 03/64] WIP: clean up the extension design --- .../Share/Views/ShareExtensionView.swift | 51 +++++++++++++++++++ .../ShareExtensionViewController.swift | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 2004f9600..16f960923 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -379,6 +379,57 @@ public struct ShareExtensionView: View { } public var body: some View { + VStack(alignment: .leading) { + HStack { + Text("Saved to Omnivore") + .font(Font.system(size: 22, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + Button(action: {}, label: { + ZStack { + Circle() + .foregroundColor(Color(hex: "#3D3D3D")) + .frame(width: 30, height: 30) + + Image(systemName: "xmark") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color(hex: "#D9D9D9")) + .aspectRatio(contentMode: .fit) + .font(Font.title.weight(.medium)) + .frame(width: 10, height: 10) + } + }) + Button(action: {}, label: { + ZStack { + Circle() + .foregroundColor(Color(hex: "#3D3D3D")) + .frame(width: 30, height: 30) + + Image(systemName: "xmark") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color(hex: "#D9D9D9")) + .aspectRatio(contentMode: .fit) + .font(Font.title.weight(.medium)) + .frame(width: 10, height: 10) + } + }) + }.padding(20) + + Spacer() + + HStack { + Spacer() + Button(action: {}, label: { Text("Read Now").font(Font.system(size: 17, weight: .semibold)).padding(20).tint(Color.white) }) + .frame(height: 50) + .background(Color.blue) + .cornerRadius(24) + }.frame(maxWidth: .infinity) + .padding(20) + } + } + + public var oldbody: some View { VStack(alignment: .center) { Capsule() .fill(.gray) diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index c35430eb3..7b1384a64 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -12,7 +12,7 @@ import Utils embed( childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), - heightRatio: 0.75 + heightRatio: 0.50 ) } } From 8fe51a5f800cddb646d2ca8f2f3cafedb3d98c2d Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 27 Oct 2023 09:56:41 +0800 Subject: [PATCH 04/64] More on ios share extension improvements --- .../Share/ShareExtensionViewModel.swift | 12 +- .../Share/Views/AddNoteSheet.swift | 46 +++ .../Share/Views/ShareExtensionView.swift | 266 +++++++++++++----- .../Sources/Models/PageScrapePayload.swift | 12 +- .../Services/DataService/DataService.swift | 2 +- .../Sources/Views/Colors/Colors.swift | 6 + .../Contents.json | 38 +++ .../Contents.json | 38 +++ .../Contents.json | 38 +++ .../Contents.json | 38 +++ .../Contents.json | 38 +++ .../Sources/Views/SyncingIcon.swift | 2 +- .../ShareExtensionViewController.swift | 20 +- 13 files changed, 477 insertions(+), 79 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 1602d0421..0dfd96a14 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -9,6 +9,7 @@ public class ShareExtensionViewModel: ObservableObject { @Published public var status: ShareExtensionStatus = .processing @Published public var title: String = "" @Published public var url: String? + @Published public var iconURL: URL? @Published public var highlightData: HighlightData? @Published public var linkedItem: LinkedItem? @Published public var requestId = UUID().uuidString.lowercased() @@ -88,9 +89,10 @@ public class ShareExtensionViewModel: ObservableObject { let hostname = URL(string: payload.url)?.host ?? "" switch payload.contentType { - case let .html(html: _, title: title, highlightData: highlightData): + case let .html(html: _, title: title, iconURL: iconURL, highlightData: highlightData): self.title = title ?? "" self.url = hostname + self.iconURL = iconURL self.highlightData = highlightData case .none: self.url = hostname @@ -145,7 +147,7 @@ public class ShareExtensionViewModel: ObservableObject { localPdfURL: localUrl, url: pageScrapePayload.url ) - case let .html(html, title, _): + case let .html(html, title, _, _): newRequestID = try await services.dataService.createPage( id: requestId, originalHtml: html, @@ -187,7 +189,11 @@ public class ShareExtensionViewModel: ObservableObject { if let title = self.linkedItem?.title { self.title = title } - self.url = self.linkedItem?.pageURLString + if let urlStr = self.linkedItem?.pageURLString, let hostname = URL(string: urlStr)?.host { + self.url = hostname + } else { + self.url = self.linkedItem?.pageURLString + } } } } diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift new file mode 100644 index 000000000..e599eb797 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift @@ -0,0 +1,46 @@ +// +// AddNoteSheet.swift +// +// +// Created by Jackson Harper on 10/26/23. +// + +import Models +import Services +import SwiftUI +import Utils +import Views + +public struct AddNoteSheet: View { + @State var text = "" + + enum FocusField: Hashable { + case noteEditor + } + + @FocusState private var focusedField: FocusField? + + public init() { + UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2) + } + + public var body: some View { + NavigationView { + TextEditor(text: $text) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .noteEditor) + .task { + self.focusedField = .noteEditor + } + .background(Color.extensionPanelBackground) + .navigationTitle("Add Note") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: {}, label: { + Text("Cancel") + })) + .navigationBarItems(trailing: Button(action: {}, label: { + Text("Save").bold() + })) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 16f960923..f1741b345 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -10,8 +10,6 @@ public struct ShareExtensionView: View { @StateObject var labelsViewModel = LabelsViewModel() @StateObject private var viewModel = ShareExtensionViewModel() - @State var reminderTime: ReminderTime? - @State var hideUntilReminded = false @State var previousLabels: [LinkedItemLabel]? @State var messageText: String? @State var showSearchLabels = false @@ -19,6 +17,8 @@ public struct ShareExtensionView: View { @State var viewState = ViewState.mainView @State var showHighlightInstructionAlert = false + @State var showAddNoteModal = false + enum FocusField: Hashable { case titleEditor } @@ -32,16 +32,6 @@ public struct ShareExtensionView: View { @FocusState private var focusedField: FocusField? - private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { - if selectedTime == reminderTime { - reminderTime = nil - hideUntilReminded = false - } else { - reminderTime = selectedTime - hideUntilReminded = true - } - } - private var titleText: String { switch viewModel.status { case .saved, .synced, .syncFailed(error: _): @@ -86,22 +76,22 @@ public struct ShareExtensionView: View { } } - var titleBar: some View { - HStack { - Spacer() - - Image(systemName: "checkmark.circle") - .frame(width: 15, height: 15) - .foregroundColor(.appGreenSuccess) - .opacity(isSynced ? 1.0 : 0.0) - - Text(messageText ?? titleText) - .font(.appSubheadline) - .foregroundColor(titleColor) - - Spacer() - } - } +// var titleBar: some View { +// HStack { +// Spacer() +// +// Image(systemName: "checkmark.circle") +// .frame(width: 15, height: 15) +// .foregroundColor(.appGreenSuccess) +// .opacity(isSynced ? 1.0 : 0.0) +// +// Text(messageText ?? titleText) +// .font(.appSubheadline) +// .foregroundColor(titleColor) +// +// Spacer() +// } +// } public var titleBox: some View { VStack(alignment: .trailing) { @@ -208,7 +198,7 @@ public struct ShareExtensionView: View { } } .padding(viewState == .editingLabels ? 0 : 16) - .background(viewState == .editingLabels ? Color.clear : Color.appButtonBackground) + .background(Color.extensionBackground) .frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60) .cornerRadius(8) } @@ -307,12 +297,12 @@ public struct ShareExtensionView: View { var moreActionsMenu: some View { Menu { - Button( - action: {}, - label: { - Button(LocalText.dismissButton, role: .cancel, action: {}) - } - ) + Button(action: {}, label: { + Label( + "Edit Info", + systemImage: "info.circle" + ) + }) Button(action: { if let linkedItem = self.viewModel.linkedItem { self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService, @@ -378,55 +368,191 @@ public struct ShareExtensionView: View { viewState = .mainView } - public var body: some View { - VStack(alignment: .leading) { - HStack { - Text("Saved to Omnivore") - .font(Font.system(size: 22, weight: .bold)) + var articleInfoBox: some View { + HStack(alignment: .top, spacing: 15) { + AsyncImage(url: self.viewModel.iconURL) + .frame(width: 56, height: 56).overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(.white, lineWidth: 1) + ).cornerRadius(14) + VStack(alignment: .leading) { + Text(self.viewModel.url ?? "") + .font(Font.system(size: 12)) + .lineLimit(1) + .foregroundColor(Color(hex: "EBEBF5")?.opacity(0.85)) + .frame(height: 14) + + Text(self.viewModel.title) + .font(Font.system(size: 13, weight: .semibold)) + .lineSpacing(1.25) + .foregroundColor(.appGrayTextContrast) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + .frame(height: 33) .frame(maxWidth: .infinity, alignment: .leading) + }.padding(.vertical, 2) + // Spacer() + Image(systemName: "checkmark.circle") + .frame(width: 15, height: 15) + .foregroundColor(.appGreenSuccess) + // .opacity(isSynced ? 1.0 : 0.0) + } + } - Spacer() - Button(action: {}, label: { - ZStack { - Circle() - .foregroundColor(Color(hex: "#3D3D3D")) - .frame(width: 30, height: 30) + var noteBox: some View { + Button(action: { + NotificationCenter.default.post(name: Notification.Name("ExpandForm"), object: nil) + // showAddNoteModal = true + }, label: { Text("Add note...") }) + .foregroundColor(Color.extensionTextSubtle) + .font(Font.system(size: 13, weight: .semibold)) + .frame(height: 50, alignment: .top) + .frame(maxWidth: .infinity, alignment: .leading) + } - Image(systemName: "xmark") - .resizable(resizingMode: Image.ResizingMode.stretch) - .foregroundColor(Color(hex: "#D9D9D9")) - .aspectRatio(contentMode: .fit) - .font(Font.title.weight(.medium)) - .frame(width: 10, height: 10) + var labelsBox: some View { + Button(action: {}, label: { + Label { + Text("Add Labels").font(Font.system(size: 12, weight: .medium)).tint(Color.white) + } icon: { + Image.label.resizable(resizingMode: .stretch).frame(width: 17, height: 17).tint(Color.white) + }.padding(.leading, 10).padding(.trailing, 12) + }) + .frame(height: 28) + .background(Color.blue) + .cornerRadius(24) + } + + var infoBox: some View { + VStack(alignment: .leading, spacing: 15) { + articleInfoBox + + Divider() + .frame(maxWidth: .infinity) + .frame(height: 1) + .background(Color(hex: "545458")?.opacity(0.65)) + + noteBox + + labelsBox + }.padding(15) + .background(Color.extensionPanelBackground) + .cornerRadius(14) + } + + var moreMenuButton: some View { + Menu { + Button(action: {}, label: { + Label( + "Edit Info", + systemImage: "info.circle" + ) + }) + Button(action: { + if let linkedItem = self.viewModel.linkedItem { + self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService, + objectID: linkedItem.objectID, + archived: true) + messageText = "Link Archived" + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } - }) - Button(action: {}, label: { - ZStack { - Circle() - .foregroundColor(Color(hex: "#3D3D3D")) - .frame(width: 30, height: 30) - - Image(systemName: "xmark") - .resizable(resizingMode: Image.ResizingMode.stretch) - .foregroundColor(Color(hex: "#D9D9D9")) - .aspectRatio(contentMode: .fit) - .font(Font.title.weight(.medium)) - .frame(width: 10, height: 10) + } + }, label: { + Label( + "Archive", + systemImage: "archivebox" + ) + }) + Button( + action: { + if let linkedItem = self.viewModel.linkedItem { + self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID) + messageText = "Link Removed" + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } } - }) - }.padding(20) + }, + label: { + Label("Remove", systemImage: "trash") + } + ) + } label: { + ZStack { + Circle() + .foregroundColor(Color.circleButtonBackground) + .frame(width: 30, height: 30) + + Image(systemName: "ellipsis") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color.circleButtonForeground) + .aspectRatio(contentMode: .fit) + .frame(width: 15, height: 15) + } + } + } + + var closeButton: some View { + Button(action: { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + }, label: { + ZStack { + Circle() + .foregroundColor(Color.circleButtonBackground) + .frame(width: 30, height: 30) + + Image(systemName: "xmark") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color.circleButtonForeground) + .aspectRatio(contentMode: .fit) + .font(Font.title.weight(.bold)) + .frame(width: 12, height: 12) + } + }) + } + + var titleBar: some View { + HStack { + Text("Saved to Omnivore") + .font(Font.system(size: 22, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) Spacer() + moreMenuButton + closeButton + } + } + + public var body: some View { + VStack(alignment: .leading, spacing: 15) { + titleBar + .padding(.top, 15) + + infoBox + + Spacer(minLength: 1) HStack { Spacer() - Button(action: {}, label: { Text("Read Now").font(Font.system(size: 17, weight: .semibold)).padding(20).tint(Color.white) }) + Button(action: { + viewModel.handleReadNowAction(extensionContext: extensionContext) + }, label: { + Text("Read Now") + .font(Font.system(size: 17, weight: .semibold)) + .tint(Color.white) + .padding(20) + }) .frame(height: 50) .background(Color.blue) .cornerRadius(24) + .padding(15) }.frame(maxWidth: .infinity) - .padding(20) - } + }.padding(.horizontal, 15) + .background(Color.extensionBackground) + .onAppear { + viewModel.savePage(extensionContext: extensionContext) + } } public var oldbody: some View { diff --git a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift index 28004ce07..401a6a1ad 100644 --- a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift +++ b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift @@ -28,7 +28,7 @@ public struct PageScrapePayload { public enum ContentType { case none case pdf(localUrl: URL) - case html(html: String, title: String?, highlightData: HighlightData?) + case html(html: String, title: String?, iconURL: URL?, highlightData: HighlightData?) } public let url: String @@ -49,9 +49,9 @@ public struct PageScrapePayload { self.contentType = .pdf(localUrl: localUrl) } - init(url: String, title: String?, html: String, highlightData: HighlightData?) { + init(url: String, title: String?, html: String, iconURL: URL?, highlightData: HighlightData?) { self.url = url - self.contentType = .html(html: html, title: title, highlightData: highlightData) + self.contentType = .html(html: html, title: title, iconURL: iconURL, highlightData: highlightData) } } @@ -319,6 +319,11 @@ private extension PageScrapePayload { let html = results?["originalHTML"] as? String let title = results?["title"] as? String let contentType = results?["contentType"] as? String + var iconURL: URL? + + if let urlStr = results?["iconURL"] as? String { + iconURL = URL(string: urlStr) + } // If we were not able to capture any HTML, treat this as a URL and // see if the backend can do better. @@ -336,6 +341,7 @@ private extension PageScrapePayload { return PageScrapePayload(url: url, title: title, html: html, + iconURL: iconURL, highlightData: HighlightData.make(dict: results)) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 5e3fe80f1..73f017d58 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -266,7 +266,7 @@ public final class DataService: ObservableObject { linkedItem.contentReader = "PDF" linkedItem.tempPDFURL = localUrl linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url) - case let .html(html: html, title: title, highlightData: _): + case let .html(html: html, title: title, iconURL: _, highlightData: _): linkedItem.contentReader = "WEB" linkedItem.originalHtml = html linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url) diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 5fffcf5ac..76f11d506 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -44,6 +44,12 @@ public extension Color { static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) } + static var circleButtonBackground: Color { Color("_circleButtonBackground", bundle: .module) } + static var circleButtonForeground: Color { Color("_circleButtonForeground", bundle: .module) } + static var extensionBackground: Color { Color("_extensionBackground", bundle: .module) } + static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) } + static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) } + // Apple system UIColor equivalents #if os(iOS) static var systemBackground: Color { Color(.systemBackground) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json new file mode 100644 index 000000000..0488699a4 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE9", + "green" : "0xE8", + "red" : "0xE8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3E", + "green" : "0x3C", + "red" : "0x3B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json new file mode 100644 index 000000000..2d6f53df7 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x83", + "green" : "0x81", + "red" : "0x81" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAB", + "green" : "0xA5", + "red" : "0xA5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json new file mode 100644 index 000000000..b5472bb11 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xF6", + "red" : "0xF6" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x20", + "green" : "0x20", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json new file mode 100644 index 000000000..559b615ae --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0xF2", + "red" : "0xF2" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x30", + "green" : "0x30", + "red" : "0x30" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json new file mode 100644 index 000000000..ac28096a8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x68", + "green" : "0x69", + "red" : "0x69" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x89", + "green" : "0x89", + "red" : "0x89" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift b/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift index de0dda106..64e2432ba 100644 --- a/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift +++ b/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift @@ -13,7 +13,7 @@ import Utils public struct SyncStatusIcon: View { let status: ServerSyncStatus - init(status: ServerSyncStatus) { + public init(status: ServerSyncStatus) { self.status = status } diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index 7b1384a64..06275285b 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -1,20 +1,38 @@ import App +import SwiftUI import Utils +import Views #if os(iOS) import UIKit + final class SheetViewController: UIViewController {} + @objc(ShareExtensionViewController) final class ShareExtensionViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear + NotificationCenter.default.addObserver(forName: Notification.Name("ExpandForm"), object: nil, queue: OperationQueue.main) { _ in + + self.openSheet() + } + embed( childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), - heightRatio: 0.50 + heightRatio: 0.60 ) } + + @IBAction func openSheet() { + let hostingController = UIHostingController(rootView: AddNoteSheet()) + + present(hostingController, animated: true, completion: nil) + + // Present it w/o any adjustments so it uses the default sheet presentation. + // present(sheetViewController., animated: true, completion: nil) + } } #elseif os(macOS) From 9ab9316d76ffe10668bd7af75db6946a124d9b54 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 27 Oct 2023 16:31:29 +0800 Subject: [PATCH 05/64] Update colors --- .../_extensionPanelBackground.colorset/Contents.json | 6 +++--- .../_extensionTextSubtle.colorset/Contents.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json index 559b615ae..d5f1a80a3 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0xF2", - "green" : "0xF2", - "red" : "0xF2" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json index ac28096a8..e3b0ba879 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json @@ -2,12 +2,12 @@ "colors" : [ { "color" : { - "color-space" : "srgb", + "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0x68", - "green" : "0x69", - "red" : "0x69" + "blue" : "0x89", + "green" : "0x89", + "red" : "0x89" } }, "idiom" : "universal" From 1925ac34e9112fdeb1d36dcd64489d9822f41bdd Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 27 Oct 2023 16:31:47 +0800 Subject: [PATCH 06/64] Snackbar should always be opaque --- .../Sources/App/Views/WebReader/WebReaderContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 040cc4f7c..942ea7cd2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -614,7 +614,7 @@ struct WebReaderContainerView: View { .autohideIn(2) .position(.bottom) .animation(.spring()) - .closeOnTapOutside(true) + .isOpaque(false) } .onReceive(NSNotification.readerSnackBarPublisher) { notification in if let message = notification.userInfo?["message"] as? String { From 92b2f9112bfe63c4833052b3822ad42e0090bdee Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 27 Oct 2023 16:31:53 +0800 Subject: [PATCH 07/64] Fix colors --- .../App/AppExtensions/Share/Views/ShareExtensionView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index f1741b345..7fe76a4b6 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -379,7 +379,7 @@ public struct ShareExtensionView: View { Text(self.viewModel.url ?? "") .font(Font.system(size: 12)) .lineLimit(1) - .foregroundColor(Color(hex: "EBEBF5")?.opacity(0.85)) + .foregroundColor(Color.extensionTextSubtle) .frame(height: 14) Text(self.viewModel.title) From fa82917f552fd54e489c7c884277e71225aa1032 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 10:33:56 +0800 Subject: [PATCH 08/64] Update view models --- .../Share/ShareExtensionScene.swift | 10 +- .../Share/ShareExtensionViewModel.swift | 3 + .../Share/Views/AddNoteSheet.swift | 33 +- .../Share/Views/EditLabelsSheet.swift | 98 +++++ .../Share/Views/ShareExtensionView.swift | 367 ++++++------------ .../App/Views/Labels/LabelsMasonaryView.swift | 1 + .../App/Views/Labels/LabelsViewModel.swift | 4 +- .../Utils/UIViewControllerExtensions.swift | 17 - .../OmnivoreKit/Sources/Views/SearchBar.swift | 2 +- .../ShareExtensionViewController.swift | 34 +- 10 files changed, 279 insertions(+), 290 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index 222ca0388..2437a21ed 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -3,11 +3,17 @@ import Utils import Views public extension PlatformViewController { - static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController { + static func makeShareExtensionController( + viewModel: ShareExtensionViewModel, + labelsViewModel: LabelsViewModel, + extensionContext: NSExtensionContext? + ) -> PlatformViewController { registerFonts() let hostingController = PlatformHostingController( - rootView: ShareExtensionView(extensionContext: extensionContext) + rootView: ShareExtensionView(viewModel: viewModel, + labelsViewModel: labelsViewModel, + extensionContext: extensionContext) ) #if os(iOS) hostingController.view.layer.cornerRadius = 12 diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 0dfd96a14..37792c953 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -14,10 +14,13 @@ public class ShareExtensionViewModel: ObservableObject { @Published public var linkedItem: LinkedItem? @Published public var requestId = UUID().uuidString.lowercased() @Published var debugText: String? + @Published var noteText: String = "" let services = Services() let queue = OperationQueue() + public init() {} + func handleReadNowAction(extensionContext: NSExtensionContext?) { #if os(iOS) if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication { diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift index e599eb797..6a613d386 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift @@ -12,7 +12,11 @@ import Utils import Views public struct AddNoteSheet: View { - @State var text = "" + @Environment(\.dismiss) private var dismiss + + @StateObject var viewModel: ShareExtensionViewModel + let highlightId = UUID().uuidString.lowercased() + let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8) enum FocusField: Hashable { case noteEditor @@ -20,13 +24,25 @@ public struct AddNoteSheet: View { @FocusState private var focusedField: FocusField? - public init() { - UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2) + public init(viewModel: ShareExtensionViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4) + } + + func saveNote() { + if let linkedItem = viewModel.linkedItem { + _ = viewModel.services.dataService.createNote(shortId: shortId, + highlightID: highlightId, + articleId: linkedItem.unwrappedID, + annotation: viewModel.noteText) + } else { + // Maybe we shouldn't even allow this UI without linkeditem existing + } } public var body: some View { NavigationView { - TextEditor(text: $text) + TextEditor(text: $viewModel.noteText) .frame(maxWidth: .infinity, maxHeight: .infinity) .focused($focusedField, equals: .noteEditor) .task { @@ -35,10 +51,15 @@ public struct AddNoteSheet: View { .background(Color.extensionPanelBackground) .navigationTitle("Add Note") .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: {}, label: { + .navigationBarItems(leading: Button(action: { + dismiss() + }, label: { Text("Cancel") })) - .navigationBarItems(trailing: Button(action: {}, label: { + .navigationBarItems(trailing: Button(action: { + saveNote() + dismiss() + }, label: { Text("Save").bold() })) } diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift new file mode 100644 index 000000000..77fe27ff6 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift @@ -0,0 +1,98 @@ +// +// EditLabelsSheet.swift +// +// +// Created by Jackson Harper on 10/27/23. +// + +import Models +import Services +import SwiftUI +import Utils +import Views + +@MainActor +public struct EditLabelsSheet: View { + @State var text = "" + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var dataService: DataService + + @StateObject var labelsViewModel: LabelsViewModel + @StateObject var viewModel: ShareExtensionViewModel + + enum FocusField: Hashable { + case noteEditor + } + + @FocusState private var focusedField: FocusField? + + public init(viewModel: ShareExtensionViewModel, labelsViewModel: LabelsViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + _labelsViewModel = StateObject(wrappedValue: labelsViewModel) + + UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2) + } + + func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) { + if labelsViewModel.selectedLabels.contains(label) { + labelsViewModel.selectedLabels.remove(label) + } else { + labelsViewModel.selectedLabels.insert(label) + } + + if let linkedItem = viewModel.linkedItem { + labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService) + } + } + + var content: some View { + VStack(spacing: 15) { + SearchBar(searchTerm: $labelsViewModel.labelSearchFilter) + + // swiftlint:disable line_length + ScrollView { + LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), + selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), + onLabelTap: onLabelTap) + Button( + action: { labelsViewModel.showCreateLabelModal = true }, + label: { + HStack { + let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines) + Image(systemName: "tag").foregroundColor(.blue) + Text( + labelsViewModel.labelSearchFilter.count > 0 ? + "Create: \"\(trimmedLabelName)\" label" : + LocalText.createLabelMessage + ).foregroundColor(.blue) + .font(Font.system(size: 14)) + Spacer() + } + } + ) + .buttonStyle(PlainButtonStyle()) + .padding(10) + } + } + .background(Color.clear) + .padding(20) + } + + public var body: some View { + NavigationView { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.extensionBackground) + .navigationTitle("Set Labels") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button(action: { + dismiss() + }, label: { + Text("Done").bold() + })) + } + .task { + await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 7fe76a4b6..a8ff83677 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -7,8 +7,8 @@ import Views // swiftlint:disable file_length type_body_length public struct ShareExtensionView: View { let extensionContext: NSExtensionContext? - @StateObject var labelsViewModel = LabelsViewModel() - @StateObject private var viewModel = ShareExtensionViewModel() + @StateObject var viewModel: ShareExtensionViewModel + @StateObject var labelsViewModel: LabelsViewModel @State var previousLabels: [LinkedItemLabel]? @State var messageText: String? @@ -32,6 +32,15 @@ public struct ShareExtensionView: View { @FocusState private var focusedField: FocusField? + public init(viewModel: ShareExtensionViewModel, + labelsViewModel: LabelsViewModel, + extensionContext: NSExtensionContext?) + { + _viewModel = StateObject(wrappedValue: viewModel) + _labelsViewModel = StateObject(wrappedValue: labelsViewModel) + self.extensionContext = extensionContext + } + private var titleText: String { switch viewModel.status { case .saved, .synced, .syncFailed(error: _): @@ -132,76 +141,76 @@ public struct ShareExtensionView: View { } } - var labelsSection: some View { - HStack { - if viewState != .editingLabels { - ZStack { - Circle() - .foregroundColor(Color.blue) - .frame(width: 34, height: 34) - - Image(systemName: "tag") - .font(.appCallout) - .frame(width: 34, height: 34) - } - .padding(.trailing, 8) - - VStack { - Text(LocalText.labelsGeneric) - .font(.appSubheadline) - .foregroundColor(Color.appGrayTextContrast) - .frame(maxWidth: .infinity, alignment: .leading) - - let labelCount = labelsViewModel.selectedLabels.count - Text(labelCount > 0 ? - "\(labelCount) label\(labelCount > 1 ? "s" : "") selected" - : "Add labels to your saved link") - .font(.appFootnote) - .foregroundColor(Color.appGrayText) - .frame(maxWidth: .infinity, alignment: .leading) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.appCallout) - } else { - VStack(spacing: 15) { - SearchBar(searchTerm: $labelsViewModel.labelSearchFilter) - - // swiftlint:disable line_length - ScrollView { - LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), - selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), - onLabelTap: onLabelTap) - Button( - action: { labelsViewModel.showCreateLabelModal = true }, - label: { - HStack { - let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines) - Image(systemName: "tag").foregroundColor(.blue) - Text( - labelsViewModel.labelSearchFilter.count > 0 ? - "Create: \"\(trimmedLabelName)\" label" : - LocalText.createLabelMessage - ).foregroundColor(.blue) - .font(Font.system(size: 14)) - Spacer() - } - } - ) - .buttonStyle(PlainButtonStyle()) - .padding(10) - }.background(Color.appButtonBackground) - // swiftlint:enable line_length - } - } - } - .padding(viewState == .editingLabels ? 0 : 16) - .background(Color.extensionBackground) - .frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60) - .cornerRadius(8) - } +// var labelsSection: some View { +// HStack { +// if viewState != .editingLabels { +// ZStack { +// Circle() +// .foregroundColor(Color.blue) +// .frame(width: 34, height: 34) +// +// Image(systemName: "tag") +// .font(.appCallout) +// .frame(width: 34, height: 34) +// } +// .padding(.trailing, 8) +// +// VStack { +// Text(LocalText.labelsGeneric) +// .font(.appSubheadline) +// .foregroundColor(Color.appGrayTextContrast) +// .frame(maxWidth: .infinity, alignment: .leading) +// +// let labelCount = labelsViewModel.selectedLabels.count +// Text(labelCount > 0 ? +// "\(labelCount) label\(labelCount > 1 ? "s" : "") selected" +// : "Add labels to your saved link") +// .font(.appFootnote) +// .foregroundColor(Color.appGrayText) +// .frame(maxWidth: .infinity, alignment: .leading) +// } +// +// Spacer() +// +// Image(systemName: "chevron.right") +// .font(.appCallout) +// } else { +// VStack(spacing: 15) { +// SearchBar(searchTerm: $labelsViewModel.labelSearchFilter) +// +// // swiftlint:disable line_length +// ScrollView { +// LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), +// selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), +// onLabelTap: onLabelTap) +// Button( +// action: { labelsViewModel.showCreateLabelModal = true }, +// label: { +// HStack { +// let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines) +// Image(systemName: "tag").foregroundColor(.blue) +// Text( +// labelsViewModel.labelSearchFilter.count > 0 ? +// "Create: \"\(trimmedLabelName)\" label" : +// LocalText.createLabelMessage +// ).foregroundColor(.blue) +// .font(Font.system(size: 14)) +// Spacer() +// } +// } +// ) +// .buttonStyle(PlainButtonStyle()) +// .padding(10) +// }.background(Color.appButtonBackground) +// // swiftlint:enable line_length +// } +// } +// } +// .padding(viewState == .editingLabels ? 0 : 16) +// .background(Color.extensionBackground) +// .frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60) +// .cornerRadius(8) +// } var highlightSection: some View { HStack { @@ -249,52 +258,6 @@ public struct ShareExtensionView: View { .cornerRadius(8) } - func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) { - if labelsViewModel.selectedLabels.contains(label) { - labelsViewModel.selectedLabels.remove(label) - } else { - labelsViewModel.selectedLabels.insert(label) - } - - if let linkedItem = viewModel.linkedItem { - labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService) - } - } - - var primaryButtons: some View { - HStack { - Button( - action: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, - label: { - Label("Read Now", systemImage: "book") - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - ) - .foregroundColor(.appGrayTextContrast) - .background(Color.appButtonBackground) - .frame(height: 52) - .cornerRadius(8) - - Spacer(minLength: 8) - - Button( - action: { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - }, - label: { - Label(LocalText.readLaterGeneric, systemImage: "text.book.closed.fill") - .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - ) - .foregroundColor(.black) - .background(Color.appBackground) - .frame(height: 52) - .cornerRadius(8) - } - } - var moreActionsMenu: some View { Menu { Button(action: {}, label: { @@ -370,11 +333,21 @@ public struct ShareExtensionView: View { var articleInfoBox: some View { HStack(alignment: .top, spacing: 15) { - AsyncImage(url: self.viewModel.iconURL) - .frame(width: 56, height: 56).overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(.white, lineWidth: 1) - ).cornerRadius(14) + AsyncImage(url: self.viewModel.iconURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 56, height: 56) + } else { + Color.appButtonBackground + .frame(width: 56, height: 56) + } + } + .frame(width: 56, height: 56).overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(.white, lineWidth: 1) + ).cornerRadius(14) VStack(alignment: .leading) { Text(self.viewModel.url ?? "") .font(Font.system(size: 12)) @@ -399,19 +372,31 @@ public struct ShareExtensionView: View { } } + var hasNoteText: Bool { + !viewModel.noteText.isEmpty + } + var noteBox: some View { Button(action: { - NotificationCenter.default.post(name: Notification.Name("ExpandForm"), object: nil) - // showAddNoteModal = true - }, label: { Text("Add note...") }) - .foregroundColor(Color.extensionTextSubtle) + NotificationCenter.default.post(name: Notification.Name("ShowAddNoteSheet"), object: nil) + }, label: { + Text(hasNoteText ? viewModel.noteText : "Add note...") + .frame(height: 50, alignment: .top) + .frame(maxWidth: .infinity, alignment: .leading) + }) + .foregroundColor(hasNoteText ? + Color.appGrayTextContrast : Color.extensionTextSubtle + ) .font(Font.system(size: 13, weight: .semibold)) .frame(height: 50, alignment: .top) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } var labelsBox: some View { - Button(action: {}, label: { + Button(action: { + NotificationCenter.default.post(name: Notification.Name("ShowEditLabelsSheet"), object: nil) + }, label: { Label { Text("Add Labels").font(Font.system(size: 12, weight: .medium)).tint(Color.white) } icon: { @@ -546,7 +531,7 @@ public struct ShareExtensionView: View { .frame(height: 50) .background(Color.blue) .cornerRadius(24) - .padding(15) + .padding(.bottom, 15) }.frame(maxWidth: .infinity) }.padding(.horizontal, 15) .background(Color.extensionBackground) @@ -554,128 +539,4 @@ public struct ShareExtensionView: View { viewModel.savePage(extensionContext: extensionContext) } } - - public var oldbody: some View { - VStack(alignment: .center) { - Capsule() - .fill(.gray) - .frame(width: 60, height: 4) - .padding(.top, 10) - - if viewState == .mainView { - titleBar - .padding(.top, 10) - .padding(.bottom, 12) - } else { - ZStack { - Text(editingViewTitle).bold() - .frame(maxWidth: .infinity, alignment: .center) - - Button(action: { - withAnimation { - submitEditTitle() - } - }, label: { Text(LocalText.doneGeneric).bold() }) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(8) - .padding(.bottom, 4) - } - - if viewState == .mainView { - titleBox - } - - if viewState == .editingTitle { - ScrollView(showsIndicators: false) { - VStack(alignment: .center, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - TextEditor(text: $viewModel.title) - .textFieldStyle(.roundedBorder) - .lineSpacing(6) - .submitLabel(.done) - .accentColor(.appGraySolid) - .foregroundColor(.appGrayTextContrast) - .font(.appSubheadline) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.appGrayBorder, lineWidth: 1) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground)) - ) - .frame(height: 100) - .focused($focusedField, equals: .titleEditor) - .task { - self.focusedField = .titleEditor - } - .onChange(of: viewModel.title) { text in - if text.last?.isNewline == .some(true) { - viewModel.title.removeLast() - submitEditTitle() - } - } - } - } - .padding(8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Spacer() - } - - if viewState != .editingTitle { - if viewState != .viewingHighlight { - labelsSection - .onTapGesture { - withAnimation { - previousLabels = Array(self.labelsViewModel.selectedLabels) - viewState = .editingLabels - } - } - } - if viewState != .editingLabels { - highlightSection - .onTapGesture { - withAnimation { - if viewModel.highlightData != nil { - viewState = .viewingHighlight - } else { - showHighlightInstructionAlert = true - } - } - } - } - } - - Spacer() - - if viewState == .mainView { - Divider() - .padding(.bottom, 20) - - primaryButtons - - moreActionsMenu - } - } - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .padding(.horizontal, 16) - .onAppear { - viewModel.savePage(extensionContext: extensionContext) - } - .sheet(isPresented: $labelsViewModel.showCreateLabelModal) { - CreateLabelView(viewModel: labelsViewModel, newLabelName: labelsViewModel.labelSearchFilter) - } - .alert("Before saving an article select text in Safari to create a highlight on save.", - isPresented: $showHighlightInstructionAlert) { - Button(LocalText.genericOk, role: .cancel) { showHighlightInstructionAlert = false } - } - .task { - await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService) - }.environmentObject(viewModel.services.dataService) - } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift index 65c53faa6..19cfef672 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift @@ -11,6 +11,7 @@ import SwiftUI import Models import Views +@MainActor struct LabelsMasonaryView: View { var onLabelTap: (LinkedItemLabel, TextChip) -> Void diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 50b11ca6a..fe7c77b05 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -4,7 +4,7 @@ import Services import SwiftUI import Views -@MainActor final class LabelsViewModel: ObservableObject { +@MainActor public final class LabelsViewModel: ObservableObject { let labelNameMaxLength = 64 @Published var isLoading = false @@ -14,6 +14,8 @@ import Views @Published var showCreateLabelModal = false @Published var labelSearchFilter = "" + public init() {} + func setLabels(_ labels: [LinkedItemLabel]) { self.labels = labels.sorted { left, right in let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces) diff --git a/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift b/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift index 9b8fcf751..dbb9f2397 100644 --- a/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift +++ b/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift @@ -48,23 +48,6 @@ child.didMove(toParent: self) } -// -// @objc func keyboardWillShow(notification: Notification) { -// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue { -// if self.view.frame.origin.y == 0{ -// self.view.frame.origin.y -= keyboardSize.height -// } -// } -// -// } -// -// @objc func keyboardWillHide(notification: Notification) { -// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue { -// if self.view.frame.origin.y != 0 { -// self.view.frame.origin.y += keyboardSize.height -// } -// } -// } } #endif diff --git a/apple/OmnivoreKit/Sources/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/Views/SearchBar.swift index e11c75ce5..ae9dc8957 100644 --- a/apple/OmnivoreKit/Sources/Views/SearchBar.swift +++ b/apple/OmnivoreKit/Sources/Views/SearchBar.swift @@ -12,7 +12,7 @@ public struct SearchBar: View { public var body: some View { HStack(spacing: 0) { - TextField("Search", text: $searchTerm) + TextField("Add Labels", text: $searchTerm) .frame(height: 36) .frame(maxWidth: .infinity) .padding(.leading, 28) diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index 06275285b..f72d5798f 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -1,4 +1,5 @@ import App +import Services import SwiftUI import Utils import Views @@ -6,32 +7,45 @@ import Views #if os(iOS) import UIKit - final class SheetViewController: UIViewController {} - @objc(ShareExtensionViewController) final class ShareExtensionViewController: UIViewController { + let labelsViewModel = LabelsViewModel() + let viewModel = ShareExtensionViewModel() + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear - NotificationCenter.default.addObserver(forName: Notification.Name("ExpandForm"), object: nil, queue: OperationQueue.main) { _ in + NotificationCenter.default.addObserver( + forName: Notification.Name("ShowAddNoteSheet"), + object: nil, + queue: OperationQueue.main + ) { _ in + self.openSheet(AnyView(AddNoteSheet(viewModel: self.viewModel))) + } - self.openSheet() + NotificationCenter.default.addObserver( + forName: Notification.Name("ShowEditLabelsSheet"), + object: nil, + queue: OperationQueue.main + ) { _ in + self.openSheet(AnyView(EditLabelsSheet(viewModel: self.viewModel, labelsViewModel: self.labelsViewModel))) } embed( - childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext), + childViewController: UIViewController.makeShareExtensionController( + viewModel: viewModel, + labelsViewModel: labelsViewModel, + extensionContext: extensionContext + ), heightRatio: 0.60 ) } - @IBAction func openSheet() { - let hostingController = UIHostingController(rootView: AddNoteSheet()) + func openSheet(_ rootView: AnyView) { + let hostingController = UIHostingController(rootView: rootView) present(hostingController, animated: true, completion: nil) - - // Present it w/o any adjustments so it uses the default sheet presentation. - // present(sheetViewController., animated: true, completion: nil) } } From bd3d8b1b9d5d1d6b546b5eedf8973860aeb64d41 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 11:25:18 +0800 Subject: [PATCH 09/64] Add intercomHash GQL definition --- .../Services/DataService/GQLSchema.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 32e46f8f0..e5df26be6 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -23024,6 +23024,7 @@ extension Objects { let followersCount: [String: Int] let friendsCount: [String: Int] let id: [String: String] + let intercomHash: [String: String] let isFriend: [String: Bool] let isFullUser: [String: Bool] let name: [String: String] @@ -23070,6 +23071,10 @@ extension Objects.User: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "intercomHash": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "isFriend": if let value = try container.decode(Bool?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -23128,6 +23133,7 @@ extension Objects.User: Decodable { followersCount = map["followersCount"] friendsCount = map["friendsCount"] id = map["id"] + intercomHash = map["intercomHash"] isFriend = map["isFriend"] isFullUser = map["isFullUser"] name = map["name"] @@ -23206,6 +23212,21 @@ extension Fields where TypeLock == Objects.User { } } + func intercomHash() throws -> String? { + let field = GraphQLField.leaf( + name: "intercomHash", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.intercomHash[field.alias!] + case .mocking: + return nil + } + } + @available(*, deprecated, message: "isFriend has been replaced with viewerIsFollowing") func isFriend() throws -> Bool? { let field = GraphQLField.leaf( From 08f9beab68200c1c60a403d3b507dc1982e1a82f Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 11:25:50 +0800 Subject: [PATCH 10/64] Improve site icon fetching --- apple/Sources/ShareExtension/ShareExtension.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apple/Sources/ShareExtension/ShareExtension.js b/apple/Sources/ShareExtension/ShareExtension.js index 4581ece82..dbbdab9c9 100644 --- a/apple/Sources/ShareExtension/ShareExtension.js +++ b/apple/Sources/ShareExtension/ShareExtension.js @@ -2,10 +2,16 @@ var ShareExtension = function() {}; const iconURL = () => { try { - const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']").content - if (previewImage) { return previewImage } + const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']") + if (previewImage && previewImage.getAttribute("content")) { return previewImage.getAttribute("content") } - return document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']").href + const appleImage = document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']") + if (appleImage && appleImage.getAttribute("href")) { return appleImage.getAttribute("href") } + + const href = new URL(document.location.href) + href.pathname = '/favicon.ico' + + return href.toString() } catch {} return undefined } From 47f67c237dc81637b10a03839821b3f626bebac4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 Oct 2023 15:42:20 +0800 Subject: [PATCH 11/64] change source to pocket if item is imported from pocket --- packages/api/src/routers/svc/integrations.ts | 2 +- packages/api/src/services/save_page.ts | 10 +++++++-- packages/import-handler/src/csv.ts | 2 +- packages/import-handler/src/index.ts | 23 +++++++++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/api/src/routers/svc/integrations.ts b/packages/api/src/routers/svc/integrations.ts index d06c2d44e..1fc201d2d 100644 --- a/packages/api/src/routers/svc/integrations.ts +++ b/packages/api/src/routers/svc/integrations.ts @@ -241,7 +241,7 @@ export function integrationsServiceRouter() { // path style: imports///-.csv const dateStr = DateTime.now().toISODate() const fileUuid = uuidv4() - const fullPath = `imports/${userId}/${dateStr}/URL_LIST-${fileUuid}.csv` + const fullPath = `imports/${userId}/${dateStr}/${integrationService.name}-${fileUuid}.csv` // open a write_stream to the file const file = createGCSFile(fullPath) writeStream = file.createWriteStream({ diff --git a/packages/api/src/services/save_page.ts b/packages/api/src/services/save_page.ts index 4f436c5fe..6e6b7b954 100644 --- a/packages/api/src/services/save_page.ts +++ b/packages/api/src/services/save_page.ts @@ -36,7 +36,12 @@ const FORCE_PUPPETEER_URLS = [ TWEET_URL_REGEX, /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/, ] -const ALREADY_PARSED_SOURCES = ['puppeteer-parse', 'csv-importer', 'rss-feeder'] +const ALREADY_PARSED_SOURCES = [ + 'puppeteer-parse', + 'csv-importer', + 'rss-feeder', + 'pocket', +] const createSlug = (url: string, title?: Maybe | undefined) => { const { pathname } = new URL(url) @@ -93,7 +98,8 @@ export const savePage = async ( state: input.state || undefined, rssFeedUrl: input.rssFeedUrl, }) - const isImported = input.source === 'csv-importer' + const isImported = + input.source === 'csv-importer' || input.source === 'pocket' // always parse in backend if the url is in the force puppeteer list if (shouldParseInBackend(input)) { diff --git a/packages/import-handler/src/csv.ts b/packages/import-handler/src/csv.ts index d04852c75..fa3d209e1 100644 --- a/packages/import-handler/src/csv.ts +++ b/packages/import-handler/src/csv.ts @@ -46,7 +46,7 @@ const parseDate = (date: string): Date => { export const importCsv = async (ctx: ImportContext, stream: Stream) => { // create metrics in redis - await createMetrics(ctx.redisClient, ctx.userId, ctx.taskId, 'csv-importer') + await createMetrics(ctx.redisClient, ctx.userId, ctx.taskId, ctx.source) const parser = parse({ headers: true, diff --git a/packages/import-handler/src/index.ts b/packages/import-handler/src/index.ts index 9fa090f78..599219168 100644 --- a/packages/import-handler/src/index.ts +++ b/packages/import-handler/src/index.ts @@ -61,6 +61,7 @@ export type ImportContext = { contentHandler: ContentHandler redisClient: RedisClient taskId: string + source: string } type importHandlerFunc = (ctx: ImportContext, stream: Stream) => Promise @@ -176,13 +177,28 @@ const handlerForFile = (name: string): importHandlerFunc | undefined => { const fileName = path.parse(name).name if (fileName.startsWith('MATTER')) { return importMatterArchive - } else if (fileName.startsWith('URL_LIST')) { + } else if (fileName.startsWith('URL_LIST') || fileName.startsWith('POCKET')) { return importCsv } return undefined } +const importSource = (name: string): string => { + const fileName = path.parse(name).name + if (fileName.startsWith('MATTER')) { + return 'matter-history' + } + if (fileName.startsWith('URL_LIST')) { + return 'csv-importer' + } + if (fileName.startsWith('POCKET')) { + return 'pocket' + } + + return 'unknown' +} + const urlHandler = async ( ctx: ImportContext, url: URL, @@ -196,7 +212,7 @@ const urlHandler = async ( const result = await importURL( ctx.userId, url, - 'csv-importer', + ctx.source, ctx.taskId, state, labels && labels.length > 0 ? labels : undefined, @@ -309,7 +325,7 @@ const handleEvent = async (data: StorageEvent, redisClient: RedisClient) => { .file(data.name) .createReadStream() - const ctx = { + const ctx: ImportContext = { userId, countImported: 0, countFailed: 0, @@ -317,6 +333,7 @@ const handleEvent = async (data: StorageEvent, redisClient: RedisClient) => { contentHandler, redisClient, taskId: data.name, + source: importSource(data.name), } await handler(ctx, stream) From 5edba30e232cae1da41ddc2c9e27eeb200ef45ed Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 Oct 2023 18:38:39 +0800 Subject: [PATCH 12/64] create an integration handler for syncing with readwise --- packages/api/src/generated/graphql.ts | 1 + packages/api/src/generated/schema.graphql | 1 + .../api/src/resolvers/integrations/index.ts | 1 + packages/api/src/schema.ts | 1 + packages/integration-handler/.eslintignore | 4 + packages/integration-handler/.eslintrc | 6 + .../integration-handler/mocha-config.json | 5 + packages/integration-handler/package.json | 56 ++++++++ packages/integration-handler/src/index.ts | 127 +++++++++++++++++ .../src/integrations/index.ts | 82 +++++++++++ .../src/integrations/integration.ts | 33 +++++ .../src/integrations/pocket.ts | 132 ++++++++++++++++++ .../src/integrations/readwise.ts | 114 +++++++++++++++ packages/integration-handler/src/item.ts | 114 +++++++++++++++ .../test/babel-register.js | 3 + .../integration-handler/test/stub.test.ts | 8 ++ packages/integration-handler/tsconfig.json | 11 ++ 17 files changed, 699 insertions(+) create mode 100644 packages/integration-handler/.eslintignore create mode 100644 packages/integration-handler/.eslintrc create mode 100644 packages/integration-handler/mocha-config.json create mode 100644 packages/integration-handler/package.json create mode 100644 packages/integration-handler/src/index.ts create mode 100644 packages/integration-handler/src/integrations/index.ts create mode 100644 packages/integration-handler/src/integrations/integration.ts create mode 100644 packages/integration-handler/src/integrations/pocket.ts create mode 100644 packages/integration-handler/src/integrations/readwise.ts create mode 100644 packages/integration-handler/src/item.ts create mode 100644 packages/integration-handler/test/babel-register.js create mode 100644 packages/integration-handler/test/stub.test.ts create mode 100644 packages/integration-handler/tsconfig.json diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index fc5e22609..e42282815 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -2376,6 +2376,7 @@ export type SetIntegrationInput = { enabled: Scalars['Boolean']; id?: InputMaybe; name: Scalars['String']; + syncedAt?: InputMaybe; token: Scalars['String']; type?: InputMaybe; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 378c8bce1..dfb7c1677 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1835,6 +1835,7 @@ input SetIntegrationInput { enabled: Boolean! id: ID name: String! + syncedAt: Date token: String! type: IntegrationType } diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 8bf1b396a..5f0dce401 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -45,6 +45,7 @@ export const setIntegrationResolver = authorized< user: { id: uid }, id: input.id || undefined, type: input.type || IntegrationType.Export, + syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined, } if (input.id) { // Update diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ac13b36b9..d257b5069 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1978,6 +1978,7 @@ const schema = gql` type: IntegrationType token: String! enabled: Boolean! + syncedAt: Date } union IntegrationsResult = IntegrationsSuccess | IntegrationsError diff --git a/packages/integration-handler/.eslintignore b/packages/integration-handler/.eslintignore new file mode 100644 index 000000000..b741470fc --- /dev/null +++ b/packages/integration-handler/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +readabilityjs/ +src/generated/ diff --git a/packages/integration-handler/.eslintrc b/packages/integration-handler/.eslintrc new file mode 100644 index 000000000..e006282a6 --- /dev/null +++ b/packages/integration-handler/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "tsconfig.json" + } +} \ No newline at end of file diff --git a/packages/integration-handler/mocha-config.json b/packages/integration-handler/mocha-config.json new file mode 100644 index 000000000..44d1d24c1 --- /dev/null +++ b/packages/integration-handler/mocha-config.json @@ -0,0 +1,5 @@ +{ + "extension": ["ts"], + "spec": "test/**/*.test.ts", + "require": "test/babel-register.js" + } \ No newline at end of file diff --git a/packages/integration-handler/package.json b/packages/integration-handler/package.json new file mode 100644 index 000000000..c25454038 --- /dev/null +++ b/packages/integration-handler/package.json @@ -0,0 +1,56 @@ +{ + "name": "@omnivore/integration-handler", + "version": "1.0.0", + "description": "", + "main": "build/src/index.js", + "files": [ + "build/src" + ], + "keywords": [], + "license": "Apache-2.0", + "scripts": { + "test": "yarn mocha -r ts-node/register --config mocha-config.json", + "lint": "eslint src --ext ts,js,tsx,jsx", + "compile": "tsc", + "build": "tsc", + "start_exporter": "functions-framework --target=exporter" + }, + "devDependencies": { + "@types/chai": "^4.3.4", + "@types/chai-string": "^1.4.2", + "@types/dompurify": "^2.4.0", + "@types/fs-extra": "^11.0.1", + "@types/glob": "^8.0.1", + "@types/jsonwebtoken": "^8.5.0", + "@types/mocha": "^10.0.1", + "@types/node": "^14.11.2", + "@types/unzip-stream": "^0.3.1", + "@types/urlsafe-base64": "^1.0.28", + "@types/uuid": "^9.0.0", + "copyfiles": "^2.4.1", + "eslint-plugin-prettier": "^4.0.0" + }, + "dependencies": { + "@fast-csv/parse": "^4.3.6", + "@google-cloud/functions-framework": "3.1.2", + "@google-cloud/storage": "^7.0.1", + "@google-cloud/tasks": "^4.0.0", + "@omnivore/readability": "1.0.0", + "@sentry/serverless": "^7.30.0", + "@types/express": "^4.17.13", + "axios": "^1.2.2", + "dompurify": "^2.4.3", + "fs-extra": "^11.1.0", + "glob": "^8.1.0", + "jsonwebtoken": "^8.5.1", + "linkedom": "^0.14.21", + "nodemon": "^2.0.15", + "redis": "^4.3.1", + "unzip-stream": "^0.3.1", + "urlsafe-base64": "^1.0.0", + "uuid": "^9.0.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts new file mode 100644 index 000000000..349c7c65d --- /dev/null +++ b/packages/integration-handler/src/index.ts @@ -0,0 +1,127 @@ +import * as Sentry from '@sentry/serverless' +import * as jwt from 'jsonwebtoken' +import { getIntegrationClient, updateIntegration } from './integrations' +import { search } from './item' + +interface ExportRequest { + integrationId: string + syncAt: number // unix timestamp in milliseconds + integrationName: string +} + +interface Claims { + uid: string + token: string +} + +Sentry.GCPFunction.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 0, +}) + +export const wait = (ms: number): Promise => { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function isExportRequest(body: any): body is ExportRequest { + return ( + 'integrationId' in body && 'syncAt' in body && 'integrationName' in body + ) +} + +export const exporter = Sentry.GCPFunction.wrapHttpFunction( + async (req, res) => { + const JWT_SECRET = process.env.JWT_SECRET + const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT + + if (!JWT_SECRET || !REST_BACKEND_ENDPOINT) { + return res.status(500).send('Environment not configured correctly') + } + + const token = (req.query.token || req.headers.authorization) as string + if (!token) { + return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) + } + + let claims: Claims + try { + claims = jwt.verify(token, JWT_SECRET) as Claims + } catch (e) { + console.error(e) + return res.status(401).send('UNAUTHORIZED') + } + + try { + if (!isExportRequest(req.body)) { + console.error('Invalid message') + return res.status(200).send('Bad Request') + } + + const { integrationId, syncAt, integrationName } = req.body + const client = getIntegrationClient(integrationName) + + // get paginated items from the backend + let hasMore = true + let after = '0' + while (hasMore) { + const response = await search( + REST_BACKEND_ENDPOINT, + claims.token, + client.highlightOnly, + new Date(syncAt), + '50', + after + ) + + if (!response) { + console.error('failed to search for items', { + integrationId, + }) + return res.status(400).send('Failed to search') + } + + hasMore = response.data.search.pageInfo.hasNextPage + after = response.data.search.pageInfo.endCursor + const items = response.data.search.edges.map((edge) => edge.node) + if (items.length === 0) { + break + } + + const synced = await client.export(claims.token, items) + if (!synced) { + console.error('failed to export item', { + integrationId, + }) + return res.status(400).send('Failed to sync') + } + + // update integration syncedAt if successful + const updated = await updateIntegration( + REST_BACKEND_ENDPOINT, + integrationId, + items[items.length - 1].updatedAt, + integrationName, + claims.token, + token + ) + + if (!updated) { + console.error('failed to update integration', { + integrationId, + }) + return res.status(400).send('Failed to update integration') + } + + // avoid rate limiting + await wait(500) + } + } catch (err) { + console.error('export with integration failed', err) + return res.status(500).send(err) + } + + res.sendStatus(200) + } +) diff --git a/packages/integration-handler/src/integrations/index.ts b/packages/integration-handler/src/integrations/index.ts new file mode 100644 index 000000000..f3aa66913 --- /dev/null +++ b/packages/integration-handler/src/integrations/index.ts @@ -0,0 +1,82 @@ +import axios from 'axios' +import { IntegrationClient } from './integration' +import { PocketClient } from './pocket' +import { ReadwiseClient } from './readwise' + +interface SetIntegrationResponse { + data: { + setIntegration: { + integration: { + id: string + } + errorCodes: string[] + } + } +} + +const clients: IntegrationClient[] = [new ReadwiseClient(), new PocketClient()] + +export const getIntegrationClient = (name: string): IntegrationClient => { + const client = clients.find((s) => s.name === name) + if (!client) { + throw new Error(`Integration client not found: ${name}`) + } + return client +} + +export const updateIntegration = async ( + apiEndpoint: string, + id: string, + syncedAt: Date, + name: string, + integrationToken: string, + token: string +): Promise => { + const requestData = JSON.stringify({ + query: ` + mutation SetIntegration($input: SetIntegrationInput!) { + setIntegration(input: $input) { + ... on SetIntegrationSuccess { + integration { + id + enabled + } + } + ... on SetIntegrationError { + errorCodes + } + } + }`, + variables: { + id, + syncedAt, + name, + token: integrationToken, + enabled: true, + }, + }) + + try { + const response = await axios.post( + `${apiEndpoint}/graphql`, + requestData, + { + headers: { + Cookie: `auth=${token};`, + 'Content-Type': 'application/json', + 'X-OmnivoreClient': 'integration-handler', + }, + } + ) + + if (response.data.data.setIntegration.errorCodes) { + console.error(response.data.data.setIntegration.errorCodes) + return false + } + + return true + } catch (error) { + console.error(error) + return false + } +} diff --git a/packages/integration-handler/src/integrations/integration.ts b/packages/integration-handler/src/integrations/integration.ts new file mode 100644 index 000000000..78b035db9 --- /dev/null +++ b/packages/integration-handler/src/integrations/integration.ts @@ -0,0 +1,33 @@ +import { Item } from '../item' + +export interface RetrievedData { + url: string + labels?: string[] + state?: string +} +export interface RetrievedResult { + data: RetrievedData[] + hasMore?: boolean + since?: number // unix timestamp in milliseconds +} + +export interface RetrieveRequest { + token: string + since?: number // unix timestamp in milliseconds + count?: number + offset?: number +} + +export abstract class IntegrationClient { + abstract name: string + abstract apiUrl: string + highlightOnly = true + + export = async (token: string, items: Item[]): Promise => { + return Promise.resolve(false) + } + + retrieve = async (req: RetrieveRequest): Promise => { + return Promise.resolve({ data: [] }) + } +} diff --git a/packages/integration-handler/src/integrations/pocket.ts b/packages/integration-handler/src/integrations/pocket.ts new file mode 100644 index 000000000..fa4003c4d --- /dev/null +++ b/packages/integration-handler/src/integrations/pocket.ts @@ -0,0 +1,132 @@ +import axios from 'axios' +import { + IntegrationClient, + RetrievedResult, + RetrieveRequest, +} from './integration' + +interface PocketResponse { + status: number // 1 if success + complete: number // 1 if all items have been returned + list: { + [key: string]: PocketItem + } + since: number // unix timestamp in seconds + search_meta: { + search_type: string + } + error: string +} + +interface PocketItem { + item_id: string + resolved_id: string + given_url: string + resolved_url: string + given_title: string + resolved_title: string + favorite: string + status: string + excerpt: string + word_count: string + tags?: { + [key: string]: Tag + } + authors?: { + [key: string]: Author + } +} + +interface Tag { + item_id: string + tag: string +} + +interface Author { + item_id: string + author_id: string + name: string +} + +export class PocketClient extends IntegrationClient { + name = 'POCKET' + apiUrl = 'https://getpocket.com/v3' + headers = { + 'Content-Type': 'application/json', + 'X-Accept': 'application/json', + } + + retrievePocketData = async ( + accessToken: string, + since: number, // unix timestamp in seconds + count = 100, + offset = 0 + ): Promise => { + const url = `${this.apiUrl}/get` + try { + const response = await axios.post( + url, + { + consumer_key: process.env.POCKET_CONSUMER_KEY, + access_token: accessToken, + state: 'all', + detailType: 'complete', + since, + sort: 'oldest', + count, + offset, + }, + { + headers: this.headers, + timeout: 10000, // 10 seconds + } + ) + + return response.data + } catch (error) { + console.error('error retrievePocketData: ', error) + + return null + } + } + + retrieve = async ({ + token, + since = 0, + count = 100, + offset = 0, + }: RetrieveRequest): Promise => { + const pocketData = await this.retrievePocketData( + token, + since / 1000, + count, + offset + ) + if (!pocketData) { + throw new Error('Error retrieving pocket data') + } + + const pocketItems = Object.values(pocketData.list) + const statusToState: Record = { + '0': 'SUCCEEDED', + '1': 'ARCHIVED', + '2': 'DELETED', + } + const data = pocketItems.map((item) => ({ + url: item.given_url, + labels: item.tags + ? Object.values(item.tags).map((tag) => tag.tag) + : undefined, + state: statusToState[item.status], + })) + + if (pocketData.error) { + throw new Error(`Error retrieving pocket data: ${pocketData.error}`) + } + + return { + data, + since: pocketData.since * 1000, + } + } +} diff --git a/packages/integration-handler/src/integrations/readwise.ts b/packages/integration-handler/src/integrations/readwise.ts new file mode 100644 index 000000000..95bd3503d --- /dev/null +++ b/packages/integration-handler/src/integrations/readwise.ts @@ -0,0 +1,114 @@ +import axios from 'axios' +import { wait } from '..' +import { highlightUrl, Item } from '../item' +import { IntegrationClient } from './integration' + +interface ReadwiseHighlight { + // The highlight text, (technically the only field required in a highlight object) + text: string + // The title of the page the highlight is on + title?: string + // The author of the page the highlight is on + author?: string + // The URL of the page image + image_url?: string + // The URL of the page + source_url?: string + // A meaningful unique identifier for your app + source_type?: string + // One of: books, articles, tweets or podcasts + category?: string + // Annotation note attached to the specific highlight + note?: string + // Highlight's location in the source text. Used to order the highlights + location?: number + // One of: page, order or time_offset + location_type?: string + // A datetime representing when the highlight was taken in the ISO 8601 format + highlighted_at?: string + // Unique url of the specific highlight + highlight_url?: string +} + +export class ReadwiseClient extends IntegrationClient { + name = 'READWISE' + apiUrl = 'https://readwise.io/api/v2' + + export = async (token: string, items: Item[]): Promise => { + let result = true + + const highlights = items.flatMap(this.itemToReadwiseHighlight) + + // If there are no highlights, we will skip the sync + if (highlights.length > 0) { + result = await this.syncWithReadwise(token, highlights) + } + + return result + } + + itemToReadwiseHighlight = (item: Item): ReadwiseHighlight[] => { + const category = item.siteName === 'Twitter' ? 'tweets' : 'articles' + return item.highlights + .map((highlight) => { + // filter out highlights that are not of type highlight or have no quote + if (highlight.type !== 'HIGHLIGHT' || !highlight.quote) { + return undefined + } + + return { + text: highlight.quote, + title: item.title, + author: item.author || undefined, + highlight_url: highlightUrl(item.slug, highlight.id), + highlighted_at: new Date(highlight.createdAt).toISOString(), + category, + image_url: item.image || undefined, + location_type: 'order', + note: highlight.annotation || undefined, + source_type: 'omnivore', + source_url: item.url, + } + }) + .filter((highlight) => highlight !== undefined) as ReadwiseHighlight[] + } + + syncWithReadwise = async ( + token: string, + highlights: ReadwiseHighlight[], + retryCount = 0 + ): Promise => { + const url = `${this.apiUrl}/highlights` + try { + const response = await axios.post( + url, + { + highlights, + }, + { + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json', + }, + timeout: 5000, // 5 seconds + } + ) + return response.status === 200 + } catch (error) { + console.error(error) + + if (axios.isAxiosError(error)) { + if (error.response?.status === 429 && retryCount < 3) { + console.log('Readwise API rate limit exceeded, retrying...') + // wait for Retry-After seconds in the header if rate limited + // max retry count is 3 + const retryAfter = error.response?.headers['retry-after'] || '10' // default to 10 seconds + await wait(parseInt(retryAfter, 10) * 1000) + return this.syncWithReadwise(token, highlights, retryCount + 1) + } + } + + return false + } + } +} diff --git a/packages/integration-handler/src/item.ts b/packages/integration-handler/src/item.ts new file mode 100644 index 000000000..6d09b4451 --- /dev/null +++ b/packages/integration-handler/src/item.ts @@ -0,0 +1,114 @@ +import axios from 'axios' + +interface SearchResponse { + data: { + search: { + edges: Edge[] + pageInfo: { + hasNextPage: boolean + endCursor: string + } + } + } + errors?: { + message: string + }[] +} + +interface Edge { + node: Item +} + +export interface Item { + id: string + title: string + image: string | null + author: string | null + siteName: string | null + highlights: Highlight[] + slug: string + url: string + updatedAt: Date +} + +interface Highlight { + id: string + quote: string + annotation: string | null + type: string + createdAt: string +} + +export const search = async ( + apiEndpoint: string, + token: string, + highlightOnly: boolean, + updatedSince: Date, + first = '50', + after = '0' +): Promise => { + const query = `updated:${updatedSince.toISOString()} ${ + highlightOnly ? 'has:highlights' : '' + } sort:updated-asc` + + const requestData = JSON.stringify({ + query: `query Search($query: String) { + search(query: $query) { + ... on SearchSuccess { + edges { + node { + id + slug + labels { + id + } + isArchived + readingProgressPercent + title + image + author + siteName + highlights { + id + quote + annotation + type + createdAt + } + } + } + } + ... on SearchError { + errorCodes + } + } + }`, + variables: { + query, + first, + after, + }, + }) + + try { + const response = await axios.post( + `${apiEndpoint}/graphql`, + requestData, + { + headers: { + Cookie: `auth=${token};`, + 'Content-Type': 'application/json', + 'X-OmnivoreClient': 'integration-handler', + }, + } + ) + + return response.data + } catch (error) { + console.error(error) + return null + } +} + +export const highlightUrl = (slug: string, highlightId: string): string => + `https://omnivore.app/me/${slug}#${highlightId}` diff --git a/packages/integration-handler/test/babel-register.js b/packages/integration-handler/test/babel-register.js new file mode 100644 index 000000000..9e872798e --- /dev/null +++ b/packages/integration-handler/test/babel-register.js @@ -0,0 +1,3 @@ +const register = require('@babel/register').default; + +register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] }); \ No newline at end of file diff --git a/packages/integration-handler/test/stub.test.ts b/packages/integration-handler/test/stub.test.ts new file mode 100644 index 000000000..24ad25c8f --- /dev/null +++ b/packages/integration-handler/test/stub.test.ts @@ -0,0 +1,8 @@ +import 'mocha' +import { expect } from 'chai' + +describe('stub test', () => { + it('should pass', () => { + expect(true).to.be.true + }) +}) diff --git a/packages/integration-handler/tsconfig.json b/packages/integration-handler/tsconfig.json new file mode 100644 index 000000000..ea2e20c34 --- /dev/null +++ b/packages/integration-handler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./../../tsconfig.json", + "ts-node": { "files": true }, + "compilerOptions": { + "outDir": "build", + "rootDir": ".", + // Generate d.ts files + "declaration": true + }, + "include": ["src/**/*", "test/**/*"] +} From b8b20905e46eaf78e89cda9254a600c5e7d418cc Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 Oct 2023 18:42:34 +0800 Subject: [PATCH 13/64] update eslint --- packages/integration-handler/.eslintignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/integration-handler/.eslintignore b/packages/integration-handler/.eslintignore index b741470fc..b94707787 100644 --- a/packages/integration-handler/.eslintignore +++ b/packages/integration-handler/.eslintignore @@ -1,4 +1,2 @@ node_modules/ dist/ -readabilityjs/ -src/generated/ From 58d5eb6bc039ef64a6aebfb28bec6bf73c1697a8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 Oct 2023 22:50:58 +0800 Subject: [PATCH 14/64] create an API to create a cloud task for each active integration when the cronjob triggers --- packages/api/src/routers/auth/jwt_helpers.ts | 11 ++++ packages/api/src/routers/svc/integrations.ts | 62 +++++++++++++++++++- packages/api/src/util.ts | 6 ++ packages/api/src/utils/createTask.ts | 51 ++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/auth/jwt_helpers.ts b/packages/api/src/routers/auth/jwt_helpers.ts index 11f65f327..f43c155af 100644 --- a/packages/api/src/routers/auth/jwt_helpers.ts +++ b/packages/api/src/routers/auth/jwt_helpers.ts @@ -85,3 +85,14 @@ export function suggestedUsername(name: string): string { const suffix = Math.floor(Math.random() * 10000) return `${prefix}${suffix}` } + +export async function createWebAuthTokenWithPayload( + payload: Record +): Promise { + try { + const authToken = await signToken(payload, env.server.jwtSecret) + return authToken as string + } catch { + return undefined + } +} diff --git a/packages/api/src/routers/svc/integrations.ts b/packages/api/src/routers/svc/integrations.ts index 1fc201d2d..ae7a2c128 100644 --- a/packages/api/src/routers/svc/integrations.ts +++ b/packages/api/src/routers/svc/integrations.ts @@ -5,9 +5,10 @@ import { stringify } from 'csv-stringify' import express from 'express' import { DateTime } from 'luxon' import { v4 as uuidv4 } from 'uuid' -import { IntegrationType } from '../../entity/integration' +import { Integration, IntegrationType } from '../../entity/integration' import { LibraryItem } from '../../entity/library_item' import { EntityType, readPushSubscription } from '../../pubsub' +import { getRepository } from '../../repository' import { Claims } from '../../resolvers/types' import { findIntegration, @@ -19,9 +20,11 @@ import { searchLibraryItems, } from '../../services/library_item' import { getClaimsByToken } from '../../utils/auth' +import { enqueueExportToIntegration } from '../../utils/createTask' import { logger } from '../../utils/logger' import { DateFilter } from '../../utils/search' import { createGCSFile } from '../../utils/uploads' +import { createWebAuthTokenWithPayload } from '../auth/jwt_helpers' export interface Message { type?: EntityType @@ -41,6 +44,63 @@ const isImportEvent = (event: any): event is ImportEvent => export function integrationsServiceRouter() { const router = express.Router() + router.post('/export', async (req, res) => { + logger.info('start to sync with integration') + + try { + const { message: msgStr, expired } = readPushSubscription(req) + if (!msgStr) { + return res.status(200).send('Bad Request') + } + + if (expired) { + logger.info('discarding expired message') + return res.status(200).send('Expired') + } + + // find all active integrations + const integrations = await getRepository(Integration).find({ + where: { + enabled: true, + type: IntegrationType.Export, + }, + relations: ['user'], + }) + + // create a task to sync with each integration + await Promise.all( + integrations.map(async (integration) => { + const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day + const authToken = await createWebAuthTokenWithPayload({ + uid: integration.user.id, + exp, + token: integration.token, + }) + + if (!authToken) { + logger.error('failed to create auth token', { + integrationId: integration.id, + }) + return + } + + const syncAt = integration.syncedAt?.getTime() || 0 + return enqueueExportToIntegration( + integration.id, + integration.name, + syncAt, + authToken + ) + }) + ) + } catch (err) { + logger.error('sync with integrations failed', err) + return res.status(500).send(err) + } + + res.status(200).send('OK') + }) + router.post('/:integrationName/:action', async (req, res) => { logger.info('start to sync with integration', { action: req.params.action, diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 0c62cd6e6..71f3978b5 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -70,6 +70,8 @@ interface BackendEnv { recommendationTaskHandlerUrl: string thumbnailTaskHandlerUrl: string rssFeedTaskHandlerUrl: string + integrationExporterUrl: string + integrationImporterUrl: string } fileUpload: { gcsUploadBucket: string @@ -163,6 +165,8 @@ const nullableEnvVars = [ 'SENDGRID_VERIFICATION_TEMPLATE_ID', 'REMINDER_TASK_HANDLER_URL', 'TRUST_PROXY', + 'INTEGRATION_EXPORTER_URL', + 'INTEGRATION_IMPORTER_URL', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -253,6 +257,8 @@ export function getEnv(): BackendEnv { recommendationTaskHandlerUrl: parse('RECOMMENDATION_TASK_HANDLER_URL'), thumbnailTaskHandlerUrl: parse('THUMBNAIL_TASK_HANDLER_URL'), rssFeedTaskHandlerUrl: parse('RSS_FEED_TASK_HANDLER_URL'), + integrationExporterUrl: parse('INTEGRATION_EXPORTER_URL'), + integrationImporterUrl: parse('INTEGRATION_IMPORTER_URL'), } const imageProxy = { url: parse('IMAGE_PROXY_URL'), diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index f6f2ac6dc..2e6b95bf1 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -543,6 +543,57 @@ export const enqueueImportFromIntegration = async ( return createdTasks[0].name } +export const enqueueExportToIntegration = async ( + integrationId: string, + integrationName: string, + syncAt: number, // unix timestamp in milliseconds + authToken: string +): Promise => { + const { GOOGLE_CLOUD_PROJECT } = process.env + const payload = { + integrationId, + integrationName, + syncAt, + } + + const headers = { + 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) { + if (env.queue.integrationTaskHandlerUrl) { + // Calling the handler function directly. + setTimeout(() => { + axios + .post(`${env.queue.integrationExporterUrl}`, payload, { + headers, + }) + .catch((error) => { + logError(error) + }) + }, 0) + } + return nanoid() + } + + const createdTasks = await createHttpTaskWithToken({ + project: GOOGLE_CLOUD_PROJECT, + payload, + taskHandlerUrl: `${env.queue.integrationExporterUrl}`, + priority: 'low', + requestHeaders: headers, + }) + + if (!createdTasks || !createdTasks[0].name) { + logger.error(`Unable to get the name of the task`, { + payload, + createdTasks, + }) + throw new CreateTaskError(`Unable to get the name of the task`) + } + return createdTasks[0].name +} + export const enqueueThumbnailTask = async ( userId: string, slug: string From 5cc9474b73c8f9dbf64aeaa5c26f4edf8336be61 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 Oct 2023 23:14:48 +0800 Subject: [PATCH 15/64] create an integration handler for importing from pocket --- packages/integration-handler/package.json | 24 +--- packages/integration-handler/src/index.ts | 134 +++++++++++++++++++++- 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/packages/integration-handler/package.json b/packages/integration-handler/package.json index c25454038..cf8a5fe0b 100644 --- a/packages/integration-handler/package.json +++ b/packages/integration-handler/package.json @@ -13,41 +13,25 @@ "lint": "eslint src --ext ts,js,tsx,jsx", "compile": "tsc", "build": "tsc", - "start_exporter": "functions-framework --target=exporter" + "start_exporter": "functions-framework --target=exporter", + "start_importer": "functions-framework --target=importer" }, "devDependencies": { "@types/chai": "^4.3.4", - "@types/chai-string": "^1.4.2", - "@types/dompurify": "^2.4.0", - "@types/fs-extra": "^11.0.1", - "@types/glob": "^8.0.1", "@types/jsonwebtoken": "^8.5.0", "@types/mocha": "^10.0.1", "@types/node": "^14.11.2", - "@types/unzip-stream": "^0.3.1", - "@types/urlsafe-base64": "^1.0.28", "@types/uuid": "^9.0.0", - "copyfiles": "^2.4.1", "eslint-plugin-prettier": "^4.0.0" }, "dependencies": { - "@fast-csv/parse": "^4.3.6", "@google-cloud/functions-framework": "3.1.2", "@google-cloud/storage": "^7.0.1", - "@google-cloud/tasks": "^4.0.0", - "@omnivore/readability": "1.0.0", "@sentry/serverless": "^7.30.0", - "@types/express": "^4.17.13", "axios": "^1.2.2", - "dompurify": "^2.4.3", - "fs-extra": "^11.1.0", - "glob": "^8.1.0", + "csv-stringify": "^6.4.0", "jsonwebtoken": "^8.5.1", - "linkedom": "^0.14.21", - "nodemon": "^2.0.15", - "redis": "^4.3.1", - "unzip-stream": "^0.3.1", - "urlsafe-base64": "^1.0.0", + "luxon": "^3.2.1", "uuid": "^9.0.0" }, "volta": { diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts index 349c7c65d..73a3c23e2 100644 --- a/packages/integration-handler/src/index.ts +++ b/packages/integration-handler/src/index.ts @@ -2,8 +2,12 @@ import * as Sentry from '@sentry/serverless' import * as jwt from 'jsonwebtoken' import { getIntegrationClient, updateIntegration } from './integrations' import { search } from './item' +import { stringify } from 'csv-stringify' +import { DateTime } from 'luxon' +import { v4 as uuidv4 } from 'uuid' +import { File, Storage } from '@google-cloud/storage' -interface ExportRequest { +interface IntegrationRequest { integrationId: string syncAt: number // unix timestamp in milliseconds integrationName: string @@ -19,18 +23,24 @@ Sentry.GCPFunction.init({ tracesSampleRate: 0, }) +const storage = new Storage() + export const wait = (ms: number): Promise => { return new Promise((resolve) => { setTimeout(resolve, ms) }) } -function isExportRequest(body: any): body is ExportRequest { +function isIntegrationRequest(body: any): body is IntegrationRequest { return ( 'integrationId' in body && 'syncAt' in body && 'integrationName' in body ) } +const createGCSFile = (bucket: string, filename: string): File => { + return storage.bucket(bucket).file(filename) +} + export const exporter = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { const JWT_SECRET = process.env.JWT_SECRET @@ -40,7 +50,8 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( return res.status(500).send('Environment not configured correctly') } - const token = (req.query.token || req.headers.authorization) as string + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const token = (req.cookies?.token || req.headers.authorization) as string if (!token) { return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) } @@ -54,7 +65,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( } try { - if (!isExportRequest(req.body)) { + if (!isIntegrationRequest(req.body)) { console.error('Invalid message') return res.status(200).send('Bad Request') } @@ -125,3 +136,118 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( res.sendStatus(200) } ) + +export const importer = Sentry.GCPFunction.wrapHttpFunction( + async (req, res) => { + const JWT_SECRET = process.env.JWT_SECRET + const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT + const GCS_BUCKET = process.env.GCS_BUCKET + + if (!JWT_SECRET || !REST_BACKEND_ENDPOINT || !GCS_BUCKET) { + return res.status(500).send('Environment not configured correctly') + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const token = (req.cookies?.token || req.headers.authorization) as string + if (!token) { + return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) + } + + let claims: Claims + try { + claims = jwt.verify(token, JWT_SECRET) as Claims + } catch (e) { + console.error(e) + return res.status(401).send('UNAUTHORIZED') + } + + if (!isIntegrationRequest(req.body)) { + console.error('Invalid message') + return res.status(200).send('Bad Request') + } + + let writeStream: NodeJS.WritableStream | undefined + try { + const userId = claims.uid + const integrationClient = getIntegrationClient(req.body.integrationName) + + let offset = 0 + let syncedAt = req.body.syncAt + const since = syncedAt + + // get pages from integration + const retrieved = await integrationClient.retrieve({ + token: claims.token, + since, + offset, + }) + syncedAt = retrieved.since || Date.now() + + let retrievedData = retrieved.data + // if there are pages to import + if (retrievedData.length > 0) { + // write the list of urls to a csv file and upload it to gcs + // path style: imports///-.csv + const dateStr = DateTime.now().toISODate() + const fileUuid = uuidv4() + const fullPath = `imports/${userId}/${dateStr}/${integrationClient.name}-${fileUuid}.csv` + // open a write_stream to the file + const file = createGCSFile(GCS_BUCKET, fullPath) + writeStream = file.createWriteStream({ + contentType: 'text/csv', + }) + // stringify the data and pipe it to the write_stream + const stringifier = stringify({ + header: true, + columns: ['url', 'state', 'labels'], + }) + stringifier.pipe(writeStream) + + // paginate api calls to the integration + do { + // write the list of urls, state and labels to the stream + retrievedData.forEach((row) => stringifier.write(row)) + + // get next pages from the integration + offset += retrievedData.length + + const retrieved = await integrationClient.retrieve({ + token: claims.token, + since, + offset, + }) + syncedAt = retrieved.since || Date.now() + retrievedData = retrieved.data + + console.log('retrieved data', { + total: offset, + size: retrievedData.length, + }) + + // update the integration's syncedAt and remove taskName + const result = await updateIntegration( + REST_BACKEND_ENDPOINT, + req.body.integrationId, + new Date(syncedAt), + req.body.integrationName, + claims.token, + token + ) + if (!result) { + console.error('failed to update integration', { + integrationId: req.body.integrationId, + }) + return res.status(400).send('Failed to update integration') + } + } while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages + } + } catch (err) { + console.error('import pages from integration failed', err) + return res.status(500).send(err) + } finally { + writeStream?.end() + } + + res.sendStatus(200) + } +) From 07eb97e7cc992a1419fee1998971dfa788173b90 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 Oct 2023 12:10:59 +0800 Subject: [PATCH 16/64] remove unused code --- .../api/src/resolvers/integrations/index.ts | 51 ++- packages/api/src/routers/auth/auth_types.ts | 5 + packages/api/src/routers/auth/jwt_helpers.ts | 15 +- packages/api/src/routers/svc/integrations.ts | 295 +----------- .../api/src/services/integrations/index.ts | 16 +- .../src/services/integrations/integration.ts | 21 +- .../api/src/services/integrations/pocket.ts | 10 +- .../api/src/services/integrations/readwise.ts | 123 +---- packages/api/src/utils/createTask.ts | 61 +-- .../api/test/resolvers/integrations.test.ts | 3 +- .../api/test/routers/integrations.test.ts | 423 ------------------ packages/api/tsconfig.json | 6 +- packages/integration-handler/.dockerignore | 5 + packages/integration-handler/.gcloudignore | 16 + .../integration-handler/Dockerfile-exporter | 26 ++ .../integration-handler/Dockerfile-importer | 26 ++ packages/integration-handler/package.json | 1 + packages/integration-handler/src/index.ts | 43 +- .../src/integrations/index.ts | 16 +- packages/integration-handler/src/item.ts | 11 +- 20 files changed, 220 insertions(+), 953 deletions(-) delete mode 100644 packages/api/test/routers/integrations.test.ts create mode 100644 packages/integration-handler/.dockerignore create mode 100644 packages/integration-handler/.gcloudignore create mode 100644 packages/integration-handler/Dockerfile-exporter create mode 100644 packages/integration-handler/Dockerfile-importer diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 5f0dce401..82a67e6b5 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -18,10 +18,11 @@ import { SetIntegrationErrorCode, SetIntegrationSuccess, } from '../../generated/graphql' +import { createIntegrationToken } from '../../routers/auth/jwt_helpers' import { findIntegration, findIntegrations, - getIntegrationService, + getIntegrationClient, removeIntegration, saveIntegration, updateIntegration, @@ -29,8 +30,8 @@ import { import { analytics } from '../../utils/analytics' import { deleteTask, + enqueueExportToIntegration, enqueueImportFromIntegration, - enqueueSyncWithIntegration, } from '../../utils/createTask' import { authorized } from '../../utils/helpers' @@ -60,7 +61,7 @@ export const setIntegrationResolver = authorized< integrationToSave.taskName = existingIntegration.taskName } else { // Create - const integrationService = getIntegrationService(input.name) + const integrationService = getIntegrationClient(input.name) // authorize and get access token const token = await integrationService.accessToken(input.token) if (!token) { @@ -74,12 +75,27 @@ export const setIntegrationResolver = authorized< // save integration const integration = await saveIntegration(integrationToSave, uid) - if ( - integrationToSave.type === IntegrationType.Export && - (!integrationToSave.id || integrationToSave.enabled) - ) { + if (integrationToSave.type === IntegrationType.Export && !input.id) { + const authToken = await createIntegrationToken({ + uid, + token: integration.token, + }) + if (!authToken) { + log.error('failed to create auth token', { + integrationId: integration.id, + }) + return { + errorCodes: [SetIntegrationErrorCode.BadRequest], + } + } + // create a task to sync all the pages if new integration or enable integration (export type) - const taskName = await enqueueSyncWithIntegration(uid, input.name) + const taskName = await enqueueExportToIntegration( + integration.id, + integration.name, + 0, + authToken + ) log.info('enqueued task', taskName) // update task name in integration @@ -191,7 +207,7 @@ export const importFromIntegrationResolver = authorized< ImportFromIntegrationSuccess, ImportFromIntegrationError, MutationImportFromIntegrationArgs ->(async (_, { integrationId }, { claims: { uid }, log, signToken }) => { +>(async (_, { integrationId }, { claims: { uid }, log }) => { log.info('importFromIntegrationResolver') try { @@ -203,14 +219,21 @@ export const importFromIntegrationResolver = authorized< } } - const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day - const authToken = (await signToken( - { uid, exp }, - env.server.jwtSecret - )) as string + const authToken = await createIntegrationToken({ + uid: integration.user.id, + token: integration.token, + }) + if (!authToken) { + return { + errorCodes: [ImportFromIntegrationErrorCode.BadRequest], + } + } + // create a task to import all the pages const taskName = await enqueueImportFromIntegration( integration.id, + integration.name, + integration.syncedAt?.getTime() || 0, authToken ) // update task name in integration diff --git a/packages/api/src/routers/auth/auth_types.ts b/packages/api/src/routers/auth/auth_types.ts index b9e07e64c..56e0139af 100644 --- a/packages/api/src/routers/auth/auth_types.ts +++ b/packages/api/src/routers/auth/auth_types.ts @@ -40,3 +40,8 @@ export function isPendingUserTokenPayload( 'username' in object ) } + +export type IntegrationTokenPayload = { + uid: string + token: string +} diff --git a/packages/api/src/routers/auth/jwt_helpers.ts b/packages/api/src/routers/auth/jwt_helpers.ts index f43c155af..941982f54 100644 --- a/packages/api/src/routers/auth/jwt_helpers.ts +++ b/packages/api/src/routers/auth/jwt_helpers.ts @@ -4,6 +4,7 @@ import { promisify } from 'util' import { env } from '../../env' import { logger } from '../../utils/logger' import { + IntegrationTokenPayload, isPendingUserTokenPayload, PendingUserTokenPayload, } from './auth_types' @@ -86,11 +87,19 @@ export function suggestedUsername(name: string): string { return `${prefix}${suffix}` } -export async function createWebAuthTokenWithPayload( - payload: Record +export async function createIntegrationToken( + payload: IntegrationTokenPayload ): Promise { try { - const authToken = await signToken(payload, env.server.jwtSecret) + const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day + const authToken = await signToken( + { + ...payload, + exp, + }, + env.server.jwtSecret + ) + logger.info('createIntegrationToken', payload) return authToken as string } catch { return undefined diff --git a/packages/api/src/routers/svc/integrations.ts b/packages/api/src/routers/svc/integrations.ts index ae7a2c128..68db6b846 100644 --- a/packages/api/src/routers/svc/integrations.ts +++ b/packages/api/src/routers/svc/integrations.ts @@ -1,45 +1,13 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { stringify } from 'csv-stringify' import express from 'express' -import { DateTime } from 'luxon' -import { v4 as uuidv4 } from 'uuid' import { Integration, IntegrationType } from '../../entity/integration' -import { LibraryItem } from '../../entity/library_item' -import { EntityType, readPushSubscription } from '../../pubsub' +import { readPushSubscription } from '../../pubsub' import { getRepository } from '../../repository' -import { Claims } from '../../resolvers/types' -import { - findIntegration, - getIntegrationService, - updateIntegration, -} from '../../services/integrations' -import { - findLibraryItemById, - searchLibraryItems, -} from '../../services/library_item' -import { getClaimsByToken } from '../../utils/auth' import { enqueueExportToIntegration } from '../../utils/createTask' import { logger } from '../../utils/logger' -import { DateFilter } from '../../utils/search' -import { createGCSFile } from '../../utils/uploads' -import { createWebAuthTokenWithPayload } from '../auth/jwt_helpers' - -export interface Message { - type?: EntityType - id?: string - userId: string - pageId?: string - articleId?: string -} - -interface ImportEvent { - integrationId: string -} - -const isImportEvent = (event: any): event is ImportEvent => - 'integrationId' in event +import { createIntegrationToken } from '../auth/jwt_helpers' export function integrationsServiceRouter() { const router = express.Router() @@ -70,10 +38,8 @@ export function integrationsServiceRouter() { // create a task to sync with each integration await Promise.all( integrations.map(async (integration) => { - const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day - const authToken = await createWebAuthTokenWithPayload({ + const authToken = await createIntegrationToken({ uid: integration.user.id, - exp, token: integration.token, }) @@ -101,260 +67,5 @@ export function integrationsServiceRouter() { res.status(200).send('OK') }) - router.post('/:integrationName/:action', async (req, res) => { - logger.info('start to sync with integration', { - action: req.params.action, - integrationName: req.params.integrationName, - }) - - try { - const { message: msgStr, expired } = readPushSubscription(req) - - if (!msgStr) { - return res.status(200).send('Bad Request') - } - - if (expired) { - logger.info('discarding expired message') - return res.status(200).send('Expired') - } - - const data: Message = JSON.parse(msgStr) - const userId = data.userId - const type = data.type - if (!userId) { - logger.info('No userId found in message') - res.status(200).send('Bad Request') - return - } - - const integration = await findIntegration( - { - name: req.params.integrationName.toUpperCase(), - type: IntegrationType.Export, - enabled: true, - }, - userId - ) - if (!integration) { - logger.info('No active integration found for user', { userId }) - res.status(200).send('No integration found') - return - } - - const action = req.params.action.toUpperCase() - const integrationService = getIntegrationService(integration.name) - if (action === 'SYNC_UPDATED') { - // get updated page by id - let id: string | undefined - switch (type) { - case EntityType.PAGE: - id = data.id - break - case EntityType.HIGHLIGHT: - id = data.articleId - break - case EntityType.LABEL: - id = data.pageId - break - } - if (!id) { - logger.info('No id found in message') - res.status(200).send('Bad Request') - return - } - const item = await findLibraryItemById(id, userId) - if (!item) { - logger.info('No item found for id', { id }) - res.status(200).send('No page found') - return - } - - // sync updated item with integration - logger.info('syncing updated item with integration', { - integrationId: integration.id, - itemId: item.id, - }) - - const synced = await integrationService.export(integration, [item]) - if (!synced) { - logger.info('failed to sync item', { - integrationId: integration.id, - itemId: item.id, - }) - return res.status(400).send('Failed to sync') - } - } else if (action === 'SYNC_ALL') { - // sync all pages of the user - const size = 50 - - for ( - let hasNextPage = true, - count = 0, - after = 0, - items: LibraryItem[] = []; - hasNextPage; - after += size, hasNextPage = count > after - ) { - const syncedAt = integration.syncedAt - // only sync pages that were updated after syncedAt - const dateFilters: DateFilter[] = [] - syncedAt && - dateFilters.push({ field: 'updatedAt', startDate: syncedAt }) - const { libraryItems } = await searchLibraryItems( - { from: after, size, dateFilters }, - userId - ) - items = libraryItems - const itemIds = items.map((p) => p.id) - - logger.info('syncing items', { pageIds: itemIds }) - - const synced = await integrationService.export(integration, items) - if (!synced) { - logger.error('failed to sync items', { - pageIds: itemIds, - integrationId: integration.id, - }) - return res.status(400).send('Failed to sync') - } - } - // delete task name if completed - await updateIntegration( - integration.id, - { - taskName: null, - }, - userId - ) - } else { - logger.info('unknown action', { action }) - res.status(200).send('Unknown action') - return - } - } catch (err) { - logger.error('sync with integrations failed', err) - return res.status(500).send(err) - } - - res.status(200).send('OK') - }) - - // import pages from integration task handler - router.post('/import', async (req, res) => { - logger.info('start cloud task to import pages from integration') - const token = req.cookies?.auth || req.headers?.authorization - let claims: Claims | undefined - try { - claims = await getClaimsByToken(token) - if (!claims) { - return res.status(401).send('UNAUTHORIZED') - } - } catch (err) { - logger.error('failed to get claims from token', err) - return res.status(401).send('UNAUTHORIZED') - } - - if (!isImportEvent(req.body)) { - logger.info('Invalid message') - return res.status(400).send('Bad Request') - } - - let writeStream: NodeJS.WritableStream | undefined - try { - const userId = claims.uid - const integration = await findIntegration( - { - id: req.body.integrationId, - enabled: true, - type: IntegrationType.Import, - }, - userId - ) - if (!integration) { - logger.info('No active integration found for user', { userId }) - return res.status(200).send('No integration found') - } - - const integrationService = getIntegrationService(integration.name) - // import pages from integration - logger.info('importing pages from integration', { - integrationId: integration.id, - }) - - let offset = 0 - const since = integration.syncedAt?.getTime() || 0 - let syncedAt = since - - // get pages from integration - const retrieved = await integrationService.retrieve({ - token: integration.token, - since, - offset, - }) - syncedAt = retrieved.since || Date.now() - - let retrievedData = retrieved.data - // if there are pages to import - if (retrievedData.length > 0) { - // write the list of urls to a csv file and upload it to gcs - // path style: imports///-.csv - const dateStr = DateTime.now().toISODate() - const fileUuid = uuidv4() - const fullPath = `imports/${userId}/${dateStr}/${integrationService.name}-${fileUuid}.csv` - // open a write_stream to the file - const file = createGCSFile(fullPath) - writeStream = file.createWriteStream({ - contentType: 'text/csv', - }) - // stringify the data and pipe it to the write_stream - const stringifier = stringify({ - header: true, - columns: ['url', 'state', 'labels'], - }) - stringifier.pipe(writeStream) - - // paginate api calls to the integration - do { - // write the list of urls, state and labels to the stream - retrievedData.forEach((row) => stringifier.write(row)) - - // get next pages from the integration - offset += retrievedData.length - - const retrieved = await integrationService.retrieve({ - token: integration.token, - since, - offset, - }) - syncedAt = retrieved.since || Date.now() - retrievedData = retrieved.data - - logger.info('retrieved data', { - total: offset, - size: retrievedData.length, - }) - } while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages - } - - // update the integration's syncedAt and remove taskName - await updateIntegration( - integration.id, - { - syncedAt: new Date(syncedAt), - taskName: null, - }, - userId - ) - } catch (err) { - logger.error('import pages from integration failed', err) - return res.status(500).send(err) - } finally { - writeStream?.end() - } - - res.status(200).send('OK') - }) - return router } diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 5927af9ac..286ac59e7 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -1,19 +1,19 @@ import { DeepPartial, FindOptionsWhere } from 'typeorm' import { Integration } from '../../entity/integration' import { authTrx } from '../../repository' -import { IntegrationService } from './integration' -import { PocketIntegration } from './pocket' -import { ReadwiseIntegration } from './readwise' +import { IntegrationClient } from './integration' +import { PocketClient } from './pocket' +import { ReadwiseClient } from './readwise' -const integrations: IntegrationService[] = [ - new ReadwiseIntegration(), - new PocketIntegration(), +const integrations: IntegrationClient[] = [ + new ReadwiseClient(), + new PocketClient(), ] -export const getIntegrationService = (name: string): IntegrationService => { +export const getIntegrationClient = (name: string): IntegrationClient => { const service = integrations.find((s) => s.name === name) if (!service) { - throw new Error(`Integration service not found: ${name}`) + throw new Error(`Integration client not found: ${name}`) } return service } diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index 7c9454543..6aa51a49f 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -1,5 +1,4 @@ -import { Integration } from '../../entity/integration' -import { LibraryItem, LibraryItemState } from '../../entity/library_item' +import { LibraryItemState } from '../../entity/library_item' export interface RetrievedData { url: string @@ -19,19 +18,9 @@ export interface RetrieveRequest { offset?: number } -export abstract class IntegrationService { - abstract name: string +export interface IntegrationClient { + name: string + apiUrl: string - accessToken = async (token: string): Promise => { - return Promise.resolve(null) - } - export = async ( - integration: Integration, - items: LibraryItem[] - ): Promise => { - return Promise.resolve(false) - } - retrieve = async (req: RetrieveRequest): Promise => { - return Promise.resolve({ data: [] }) - } + accessToken(token: string): Promise } diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 0a57bb9a6..86a99f8ef 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -3,7 +3,7 @@ import { LibraryItemState } from '../../entity/library_item' import { env } from '../../env' import { logger } from '../../utils/logger' import { - IntegrationService, + IntegrationClient, RetrievedResult, RetrieveRequest, } from './integration' @@ -51,16 +51,16 @@ interface Author { name: string } -export class PocketIntegration extends IntegrationService { +export class PocketClient implements IntegrationClient { name = 'POCKET' - POCKET_API_URL = 'https://getpocket.com/v3' + apiUrl = 'https://getpocket.com/v3' headers = { 'Content-Type': 'application/json', 'X-Accept': 'application/json', } accessToken = async (token: string): Promise => { - const url = `${this.POCKET_API_URL}/oauth/authorize` + const url = `${this.apiUrl}/oauth/authorize` try { const response = await axios.post<{ access_token: string }>( url, @@ -90,7 +90,7 @@ export class PocketIntegration extends IntegrationService { count = 100, offset = 0 ): Promise => { - const url = `${this.POCKET_API_URL}/get` + const url = `${this.apiUrl}/get` try { const response = await axios.post( url, diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index cfbbf421e..42f50f744 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -1,13 +1,6 @@ import axios from 'axios' -import { updateIntegration } from '.' -import { HighlightType } from '../../entity/highlight' -import { Integration } from '../../entity/integration' -import { LibraryItem } from '../../entity/library_item' -import { env } from '../../env' -import { wait } from '../../utils/helpers' import { logger } from '../../utils/logger' -import { findHighlightsByLibraryItemId, getHighlightUrl } from '../highlights' -import { IntegrationService } from './integration' +import { IntegrationClient } from './integration' interface ReadwiseHighlight { // The highlight text, (technically the only field required in a highlight object) @@ -36,12 +29,12 @@ interface ReadwiseHighlight { highlight_url?: string } -export const READWISE_API_URL = 'https://readwise.io/api/v2' - -export class ReadwiseIntegration extends IntegrationService { +export class ReadwiseClient implements IntegrationClient { name = 'READWISE' + apiUrl = 'https://readwise.io/api/v2' + accessToken = async (token: string): Promise => { - const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth` + const authUrl = `${this.apiUrl}/auth` try { const response = await axios.get(authUrl, { headers: { @@ -58,110 +51,4 @@ export class ReadwiseIntegration extends IntegrationService { return null } } - export = async ( - integration: Integration, - items: LibraryItem[] - ): Promise => { - let result = true - - const highlights = await Promise.all( - items.map((item) => - this.libraryItemToReadwiseHighlight(item, integration.user.id) - ) - ) - // If there are no highlights, we will skip the sync - if (highlights.length > 0) { - result = await this.syncWithReadwise(integration.token, highlights.flat()) - } - - // update integration syncedAt if successful - if (result) { - logger.info('updating integration syncedAt') - await updateIntegration( - integration.id, - { - syncedAt: new Date(), - }, - integration.user.id - ) - } - return result - } - - libraryItemToReadwiseHighlight = async ( - item: LibraryItem, - userId: string - ): Promise => { - let highlights = item.highlights - if (!highlights) { - highlights = await findHighlightsByLibraryItemId(item.id, userId) - } - - const category = item.siteName === 'Twitter' ? 'tweets' : 'articles' - return highlights - .map((highlight) => { - // filter out highlights that are not of type highlight or have no quote - if ( - highlight.highlightType !== HighlightType.Highlight || - !highlight.quote - ) { - return undefined - } - - return { - text: highlight.quote, - title: item.title, - author: item.author || undefined, - highlight_url: getHighlightUrl(item.slug, highlight.id), - highlighted_at: new Date(highlight.createdAt).toISOString(), - category, - image_url: item.thumbnail || undefined, - // location: highlight.highlightPositionAnchorIndex || undefined, - location_type: 'order', - note: highlight.annotation || undefined, - source_type: 'omnivore', - source_url: item.originalUrl, - } - }) - .filter((highlight) => highlight !== undefined) as ReadwiseHighlight[] - } - - syncWithReadwise = async ( - token: string, - highlights: ReadwiseHighlight[], - retryCount = 0 - ): Promise => { - const url = `${env.readwise.apiUrl || READWISE_API_URL}/highlights` - try { - const response = await axios.post( - url, - { - highlights, - }, - { - headers: { - Authorization: `Token ${token}`, - 'Content-Type': 'application/json', - }, - timeout: 5000, // 5 seconds - } - ) - return response.status === 200 - } catch (error) { - logger.error(error) - - if (axios.isAxiosError(error)) { - if (error.response?.status === 429 && retryCount < 3) { - logger.info('Readwise API rate limit exceeded, retrying...') - // wait for Retry-After seconds in the header if rate limited - // max retry count is 3 - const retryAfter = error.response?.headers['retry-after'] || '10' // default to 10 seconds - await wait(parseInt(retryAfter, 10) * 1000) - return this.syncWithReadwise(token, highlights, retryCount + 1) - } - } - - return false - } - } } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 2e6b95bf1..be71a64a3 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -328,47 +328,6 @@ export const enqueueReminder = async ( return createdTasks[0].name } -export const enqueueSyncWithIntegration = async ( - userId: string, - integrationName: string -): Promise => { - const { GOOGLE_CLOUD_PROJECT, PUBSUB_VERIFICATION_TOKEN } = process.env - // use pubsub data format to send the userId to the task handler - const payload = { - message: { - data: Buffer.from( - JSON.stringify({ - userId, - }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - - // If there is no Google Cloud Project Id exposed, it means that we are in local environment - if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { - return nanoid() - } - - const createdTasks = await createHttpTaskWithToken({ - project: GOOGLE_CLOUD_PROJECT, - payload, - taskHandlerUrl: `${ - env.queue.integrationTaskHandlerUrl - }/${integrationName.toLowerCase()}/sync_all?token=${PUBSUB_VERIFICATION_TOKEN}`, - priority: 'low', - }) - - if (!createdTasks || !createdTasks[0].name) { - logger.error(`Unable to get the name of the task`, { - payload, - createdTasks, - }) - throw new CreateTaskError(`Unable to get the name of the task`) - } - return createdTasks[0].name -} - export const enqueueTextToSpeech = async ({ userId, text, @@ -498,23 +457,27 @@ export const enqueueRecommendation = async ( export const enqueueImportFromIntegration = async ( integrationId: string, + integrationName: string, + syncAt: number, // unix timestamp in milliseconds authToken: string ): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { integrationId, + integrationName, + syncAt, } const headers = { - Cookie: `auth=${authToken}`, + [OmnivoreAuthorizationHeader]: 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) { - if (env.queue.integrationTaskHandlerUrl) { + if (env.queue.integrationImporterUrl) { // Calling the handler function directly. setTimeout(() => { axios - .post(`${env.queue.integrationTaskHandlerUrl}/import`, payload, { + .post(env.queue.integrationImporterUrl, payload, { headers, }) .catch((error) => { @@ -528,7 +491,7 @@ export const enqueueImportFromIntegration = async ( const createdTasks = await createHttpTaskWithToken({ project: GOOGLE_CLOUD_PROJECT, payload, - taskHandlerUrl: `${env.queue.integrationTaskHandlerUrl}/import`, + taskHandlerUrl: env.queue.integrationImporterUrl, priority: 'low', requestHeaders: headers, }) @@ -557,15 +520,15 @@ export const enqueueExportToIntegration = async ( } const headers = { - Cookie: `auth=${authToken}`, + [OmnivoreAuthorizationHeader]: 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) { - if (env.queue.integrationTaskHandlerUrl) { + if (env.queue.integrationExporterUrl) { // Calling the handler function directly. setTimeout(() => { axios - .post(`${env.queue.integrationExporterUrl}`, payload, { + .post(env.queue.integrationExporterUrl, payload, { headers, }) .catch((error) => { @@ -579,7 +542,7 @@ export const enqueueExportToIntegration = async ( const createdTasks = await createHttpTaskWithToken({ project: GOOGLE_CLOUD_PROJECT, payload, - taskHandlerUrl: `${env.queue.integrationExporterUrl}`, + taskHandlerUrl: env.queue.integrationExporterUrl, priority: 'low', requestHeaders: headers, }) diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index c170dedb4..bb9e56929 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -11,7 +11,6 @@ import { saveIntegration, updateIntegration, } from '../../src/services/integrations' -import { READWISE_API_URL } from '../../src/services/integrations/readwise' import { deleteUser } from '../../src/services/user' import { createTestUser } from '../db' import { generateFakeUuid, graphqlRequest, request } from '../util' @@ -19,6 +18,8 @@ import { generateFakeUuid, graphqlRequest, request } from '../util' chai.use(sinonChai) describe('Integrations resolvers', () => { + const READWISE_API_URL = 'https://readwise.io/api/v2' + let loginUser: User let authToken: string diff --git a/packages/api/test/routers/integrations.test.ts b/packages/api/test/routers/integrations.test.ts deleted file mode 100644 index 9081ef2f3..000000000 --- a/packages/api/test/routers/integrations.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { Storage } from '@google-cloud/storage' -import { expect } from 'chai' -import { DateTime } from 'luxon' -import 'mocha' -import nock from 'nock' -import sinon from 'sinon' -import { Highlight } from '../../src/entity/highlight' -import { Integration, IntegrationType } from '../../src/entity/integration' -import { LibraryItem } from '../../src/entity/library_item' -import { User } from '../../src/entity/user' -import { env } from '../../src/env' -import { PubSubRequestBody } from '../../src/pubsub' -import { createHighlight, getHighlightUrl } from '../../src/services/highlights' -import { - deleteIntegrations, - saveIntegration, - updateIntegration, -} from '../../src/services/integrations' -import { READWISE_API_URL } from '../../src/services/integrations/readwise' -import { deleteLibraryItemById } from '../../src/services/library_item' -import { deleteUser } from '../../src/services/user' -import { createTestLibraryItem, createTestUser } from '../db' -import { MockBucket } from '../mock_storage' -import { request } from '../util' - -describe('Integrations routers', () => { - const baseUrl = '/svc/pubsub/integrations' - let token: string - let user: User - let authToken: string - - before(async () => { - user = await createTestUser('fakeUser') - const res = await request - .post('/local/debug/fake-user-login') - .send({ fakeEmail: user.email }) - - const body = res.body as { authToken: string } - authToken = body.authToken - }) - - after(async () => { - await deleteUser(user.id) - }) - - describe('sync with integrations', () => { - const endpoint = (token: string, name = 'name', action = 'action') => - `${baseUrl}/${name}/${action}?token=${token}` - let action: string - let data: PubSubRequestBody - let integrationName: string - - context('when token is invalid', () => { - before(() => { - token = 'invalid-token' - }) - - it('returns 200', async () => { - return request.post(endpoint(token)).send(data).expect(200) - }) - }) - - context('when token is valid', () => { - before(() => { - token = process.env.PUBSUB_VERIFICATION_TOKEN as string - }) - - context('when data is expired', () => { - before(() => { - data = { - message: { - data: Buffer.from( - JSON.stringify({ userId: 'userId', type: 'page' }) - ).toString('base64'), - publishTime: DateTime.now().minus({ hours: 12 }).toISO(), - }, - } - }) - - it('returns 200 with Expired', async () => { - const res = await request.post(endpoint(token)).send(data).expect(200) - expect(res.text).to.eql('Expired') - }) - }) - - context('when userId is empty', () => { - before(() => { - data = { - message: { - data: Buffer.from( - JSON.stringify({ userId: '', type: 'page' }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - }) - - it('returns 200', async () => { - return request.post(endpoint(token)).send(data).expect(200) - }) - }) - - context('when user exists', () => { - context('when integration not found', () => { - before(() => { - integrationName = 'READWISE' - data = { - message: { - data: Buffer.from( - JSON.stringify({ userId: user.id, type: 'page' }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - }) - - it('returns 200 with No integration found', async () => { - const res = await request - .post(endpoint(token, integrationName)) - .send(data) - .expect(200) - expect(res.text).to.eql('No integration found') - }) - }) - - context('when integration is readwise and enabled', () => { - let integration: Integration - let item: LibraryItem - let highlight: Highlight - let highlightsData: any - - before(async () => { - integration = await saveIntegration( - { - user, - name: 'READWISE', - token: 'token', - }, - user.id - ) - integrationName = integration.name - // create page - item = await createTestLibraryItem(user.id) - - // create highlight - const highlightPositionPercent = 25 - highlight = await createHighlight( - { - patch: 'test patch', - quote: 'test quote', - shortId: 'test shortId', - highlightPositionPercent, - user, - libraryItem: item, - }, - item.id, - user.id - ) - // create highlights data for integration request - highlightsData = { - highlights: [ - { - text: highlight.quote, - title: item.title, - author: item.author ?? undefined, - highlight_url: getHighlightUrl(item.slug, highlight.id), - highlighted_at: highlight.createdAt.toISOString(), - category: 'articles', - image_url: item.thumbnail ?? undefined, - // location: highlightPositionPercent, - location_type: 'order', - note: highlight.annotation ?? undefined, - source_type: 'omnivore', - source_url: item.originalUrl, - }, - ], - } - }) - - after(async () => { - await deleteIntegrations(user.id, [integration.id]) - await deleteLibraryItemById(item.id) - }) - - context('when action is sync_updated', () => { - before(() => { - action = 'sync_updated' - }) - - context('when entity type is page', () => { - before(() => { - data = { - message: { - data: Buffer.from( - JSON.stringify({ - userId: user.id, - type: 'page', - id: item.id, - }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - // mock Readwise Highlight API - nock(READWISE_API_URL, { - reqheaders: { - Authorization: `Token ${integration.token}`, - 'Content-Type': 'application/json', - }, - }) - .post('/highlights', highlightsData) - .reply(200) - }) - - it('returns 200 with OK', async () => { - const res = await request - .post(endpoint(token, integrationName, action)) - .send(data) - .expect(200) - expect(res.text).to.eql('OK') - }) - - context('when readwise highlight API reaches rate limits', () => { - before(() => { - // mock Readwise Highlight API with rate limits - // retry after 1 second - nock(READWISE_API_URL, { - reqheaders: { - Authorization: `Token ${integration.token}`, - 'Content-Type': 'application/json', - }, - }) - .post('/highlights') - .reply(429, 'Rate Limited', { 'Retry-After': '1' }) - // mock Readwise Highlight API after 1 second - nock(READWISE_API_URL, { - reqheaders: { - Authorization: `Token ${integration.token}`, - 'Content-Type': 'application/json', - }, - }) - .post('/highlights') - .delay(1000) - .reply(200) - }) - - it('returns 200 with OK', async () => { - const res = await request - .post(endpoint(token, integrationName, action)) - .send(data) - .expect(200) - expect(res.text).to.eql('OK') - }) - }) - }) - - context('when entity type is highlight', () => { - before(() => { - data = { - message: { - data: Buffer.from( - JSON.stringify({ - userId: user.id, - type: 'highlight', - articleId: item.id, - }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - // mock Readwise Highlight API - nock(READWISE_API_URL, { - reqheaders: { - Authorization: `Token ${integration.token}`, - 'Content-Type': 'application/json', - }, - }) - .post('/highlights', highlightsData) - .reply(200) - }) - - it('returns 200 with OK', async () => { - const res = await request - .post(endpoint(token, integrationName, action)) - .send(data) - .expect(200) - expect(res.text).to.eql('OK') - }) - }) - }) - - context('when action is sync_all', () => { - before(async () => { - action = 'sync_all' - data = { - message: { - data: Buffer.from( - JSON.stringify({ - userId: user.id, - }) - ).toString('base64'), - publishTime: new Date().toISOString(), - }, - } - // mock Readwise Highlight API - nock(READWISE_API_URL, { - reqheaders: { - Authorization: `Token ${integration.token}`, - 'Content-Type': 'application/json', - }, - }) - .post('/highlights', highlightsData) - .reply(200) - await updateIntegration( - integration.id, - { - syncedAt: null, - taskName: 'some task name', - }, - user.id - ) - }) - - it('returns 200 with OK', async () => { - const res = await request - .post(endpoint(token, integrationName, action)) - .send(data) - .expect(200) - expect(res.text).to.eql('OK') - }) - }) - }) - }) - }) - }) - - describe('import from integrations router', () => { - let integration: Integration - - before(async () => { - token = 'test token' - // create integration - integration = await saveIntegration( - { - user: { id: user.id }, - name: 'POCKET', - token, - type: IntegrationType.Import, - }, - user.id - ) - - // mock Pocket API - const reqBody = { - access_token: token, - consumer_key: env.pocket.consumerKey, - state: 'all', - detailType: 'complete', - since: 0, - sort: 'oldest', - count: 100, - offset: 0, - } - nock('https://getpocket.com', { - reqheaders: { - 'content-type': 'application/json', - 'x-accept': 'application/json', - }, - }) - .post('/v3/get', reqBody) - .reply(200, { - complete: 1, - list: { - '123': { - given_url: 'https://omnivore.app/pocket-import-test,test', - state: '0', - tags: { - '1234': { - tag: 'test', - }, - '1235': { - tag: 'new', - }, - }, - }, - }, - since: Date.now() / 1000, - }) - .post('/v3/get', { - ...reqBody, - offset: 1, - }) - .reply(200, { - list: {}, - }) - - // mock cloud storage - const mockBucket = new MockBucket('test') - sinon.replace( - Storage.prototype, - 'bucket', - sinon.fake.returns(mockBucket as never) - ) - }) - - after(async () => { - sinon.restore() - await deleteIntegrations(user.id, [integration.id]) - }) - - context('when integration is pocket', () => { - it('returns 200 with OK', async () => { - return request - .post(`${baseUrl}/import`) - .send({ - integrationId: integration.id, - }) - .set('Cookie', `auth=${authToken}`) - .expect(200) - }) - }) - }) -}) diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 7c8caecfd..ffa966215 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -6,6 +6,10 @@ "compilerOptions": { "outDir": "dist" }, - "include": ["src", "test"], + "include": [ + "src", + "test", + "../integration-handler/test/integrations.test.ts" + ], "exclude": ["./src/generated", "./test"] } diff --git a/packages/integration-handler/.dockerignore b/packages/integration-handler/.dockerignore new file mode 100644 index 000000000..d8aea4ee6 --- /dev/null +++ b/packages/integration-handler/.dockerignore @@ -0,0 +1,5 @@ +node_modules +build +.env* +Dockerfile +.dockerignore diff --git a/packages/integration-handler/.gcloudignore b/packages/integration-handler/.gcloudignore new file mode 100644 index 000000000..ccc4eb240 --- /dev/null +++ b/packages/integration-handler/.gcloudignore @@ -0,0 +1,16 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules diff --git a/packages/integration-handler/Dockerfile-exporter b/packages/integration-handler/Dockerfile-exporter new file mode 100644 index 000000000..60eee9d5f --- /dev/null +++ b/packages/integration-handler/Dockerfile-exporter @@ -0,0 +1,26 @@ +FROM node:18.16-alpine + +# Run everything after as non-privileged user. +WORKDIR /app + +COPY package.json . +COPY yarn.lock . +COPY tsconfig.json . +COPY .eslintrc . + +COPY /packages/integration-handler/package.json ./packages/integration-handler/package.json + +RUN yarn install --pure-lockfile + +COPY /packages/integration-handler ./packages/integration-handler +RUN yarn workspace @omnivore/integration-handler build + +# After building, fetch the production dependencies +RUN rm -rf /app/packages/integration-handler/node_modules +RUN rm -rf /app/node_modules +RUN yarn install --pure-lockfile --production + +EXPOSE 8080 + +CMD ["yarn", "workspace", "@omnivore/integration-handler", "start_exporter"] + diff --git a/packages/integration-handler/Dockerfile-importer b/packages/integration-handler/Dockerfile-importer new file mode 100644 index 000000000..e3a790b8a --- /dev/null +++ b/packages/integration-handler/Dockerfile-importer @@ -0,0 +1,26 @@ +FROM node:18.16-alpine + +# Run everything after as non-privileged user. +WORKDIR /app + +COPY package.json . +COPY yarn.lock . +COPY tsconfig.json . +COPY .eslintrc . + +COPY /packages/integration-handler/package.json ./packages/integration-handler/package.json + +RUN yarn install --pure-lockfile + +COPY /packages/integration-handler ./packages/integration-handler +RUN yarn workspace @omnivore/integration-handler build + +# After building, fetch the production dependencies +RUN rm -rf /app/packages/integration-handler/node_modules +RUN rm -rf /app/node_modules +RUN yarn install --pure-lockfile --production + +EXPOSE 8080 + +CMD ["yarn", "workspace", "@omnivore/integration-handler", "start_importer"] + diff --git a/packages/integration-handler/package.json b/packages/integration-handler/package.json index cf8a5fe0b..e1ccc1fcb 100644 --- a/packages/integration-handler/package.json +++ b/packages/integration-handler/package.json @@ -30,6 +30,7 @@ "@sentry/serverless": "^7.30.0", "axios": "^1.2.2", "csv-stringify": "^6.4.0", + "dotenv": "^16.0.1", "jsonwebtoken": "^8.5.1", "luxon": "^3.2.1", "uuid": "^9.0.0" diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts index 73a3c23e2..d32ef8a08 100644 --- a/packages/integration-handler/src/index.ts +++ b/packages/integration-handler/src/index.ts @@ -1,11 +1,12 @@ +import { File, Storage } from '@google-cloud/storage' import * as Sentry from '@sentry/serverless' -import * as jwt from 'jsonwebtoken' -import { getIntegrationClient, updateIntegration } from './integrations' -import { search } from './item' import { stringify } from 'csv-stringify' +import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import +import * as jwt from 'jsonwebtoken' import { DateTime } from 'luxon' import { v4 as uuidv4 } from 'uuid' -import { File, Storage } from '@google-cloud/storage' +import { getIntegrationClient, updateIntegration } from './integrations' +import { search } from './item' interface IntegrationRequest { integrationId: string @@ -18,6 +19,8 @@ interface Claims { token: string } +dotenv.config() + Sentry.GCPFunction.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0, @@ -43,6 +46,8 @@ const createGCSFile = (bucket: string, filename: string): File => { export const exporter = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { + console.log('start to export to integration') + const JWT_SECRET = process.env.JWT_SECRET const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT @@ -50,8 +55,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( return res.status(500).send('Environment not configured correctly') } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const token = (req.cookies?.token || req.headers.authorization) as string + const token = req.get('Omnivore-Authorization') if (!token) { return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) } @@ -77,9 +81,10 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( let hasMore = true let after = '0' while (hasMore) { + console.log('searching for items...') const response = await search( REST_BACKEND_ENDPOINT, - claims.token, + token, client.highlightOnly, new Date(syncAt), '50', @@ -100,6 +105,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( break } + console.log('exporting items...') const synced = await client.export(claims.token, items) if (!synced) { console.error('failed to export item', { @@ -108,6 +114,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( return res.status(400).send('Failed to sync') } + console.log('updating integration...') // update integration syncedAt if successful const updated = await updateIntegration( REST_BACKEND_ENDPOINT, @@ -115,7 +122,8 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( items[items.length - 1].updatedAt, integrationName, claims.token, - token + token, + 'EXPORT' ) if (!updated) { @@ -128,6 +136,8 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( // avoid rate limiting await wait(500) } + + console.log('done') } catch (err) { console.error('export with integration failed', err) return res.status(500).send(err) @@ -139,16 +149,17 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction( export const importer = Sentry.GCPFunction.wrapHttpFunction( async (req, res) => { + console.log('start to import from integration') + const JWT_SECRET = process.env.JWT_SECRET const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT - const GCS_BUCKET = process.env.GCS_BUCKET + const GCS_BUCKET = process.env.GCS_UPLOAD_BUCKET if (!JWT_SECRET || !REST_BACKEND_ENDPOINT || !GCS_BUCKET) { return res.status(500).send('Environment not configured correctly') } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const token = (req.cookies?.token || req.headers.authorization) as string + const token = req.get('Omnivore-Authorization') if (!token) { return res.status(401).send({ errorCode: 'INVALID_TOKEN' }) } @@ -175,6 +186,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( let syncedAt = req.body.syncAt const since = syncedAt + console.log('importing pages from integration...') // get pages from integration const retrieved = await integrationClient.retrieve({ token: claims.token, @@ -183,6 +195,8 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( }) syncedAt = retrieved.since || Date.now() + console.log('uploading items...') + let retrievedData = retrieved.data // if there are pages to import if (retrievedData.length > 0) { @@ -224,6 +238,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( size: retrievedData.length, }) + console.log('uploading integration...') // update the integration's syncedAt and remove taskName const result = await updateIntegration( REST_BACKEND_ENDPOINT, @@ -231,7 +246,8 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( new Date(syncedAt), req.body.integrationName, claims.token, - token + token, + 'IMPORT' ) if (!result) { console.error('failed to update integration', { @@ -241,10 +257,13 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( } } while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages } + + console.log('done') } catch (err) { console.error('import pages from integration failed', err) return res.status(500).send(err) } finally { + console.log('closing write stream') writeStream?.end() } diff --git a/packages/integration-handler/src/integrations/index.ts b/packages/integration-handler/src/integrations/index.ts index f3aa66913..65c35a047 100644 --- a/packages/integration-handler/src/integrations/index.ts +++ b/packages/integration-handler/src/integrations/index.ts @@ -30,7 +30,8 @@ export const updateIntegration = async ( syncedAt: Date, name: string, integrationToken: string, - token: string + token: string, + type: string ): Promise => { const requestData = JSON.stringify({ query: ` @@ -48,11 +49,14 @@ export const updateIntegration = async ( } }`, variables: { - id, - syncedAt, - name, - token: integrationToken, - enabled: true, + input: { + id, + syncedAt, + name, + token: integrationToken, + enabled: true, + type, + }, }, }) diff --git a/packages/integration-handler/src/item.ts b/packages/integration-handler/src/item.ts index 6d09b4451..cb535904c 100644 --- a/packages/integration-handler/src/item.ts +++ b/packages/integration-handler/src/item.ts @@ -59,11 +59,8 @@ export const search = async ( node { id slug - labels { - id - } - isArchived - readingProgressPercent + url + updatedAt title image author @@ -77,6 +74,10 @@ export const search = async ( } } } + pageInfo { + hasNextPage + endCursor + } } ... on SearchError { errorCodes From c7953642c8d01b94bd237877746c083d35bb4f87 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 Oct 2023 12:21:10 +0800 Subject: [PATCH 17/64] update Dockerfile --- packages/integration-handler/Dockerfile-exporter | 4 +++- packages/integration-handler/Dockerfile-importer | 4 +++- packages/integration-handler/package.json | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/integration-handler/Dockerfile-exporter b/packages/integration-handler/Dockerfile-exporter index 60eee9d5f..eb069aec9 100644 --- a/packages/integration-handler/Dockerfile-exporter +++ b/packages/integration-handler/Dockerfile-exporter @@ -1,4 +1,6 @@ -FROM node:18.16-alpine +FROM node:18.16 + +RUN apt-get update && apt-get install -y g++ make python3 # Run everything after as non-privileged user. WORKDIR /app diff --git a/packages/integration-handler/Dockerfile-importer b/packages/integration-handler/Dockerfile-importer index e3a790b8a..ef8abcb15 100644 --- a/packages/integration-handler/Dockerfile-importer +++ b/packages/integration-handler/Dockerfile-importer @@ -1,4 +1,6 @@ -FROM node:18.16-alpine +FROM node:18.16 + +RUN apt-get update && apt-get install -y g++ make python3 # Run everything after as non-privileged user. WORKDIR /app diff --git a/packages/integration-handler/package.json b/packages/integration-handler/package.json index e1ccc1fcb..1b1077dd2 100644 --- a/packages/integration-handler/package.json +++ b/packages/integration-handler/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@types/chai": "^4.3.4", "@types/jsonwebtoken": "^8.5.0", + "@types/luxon": "^1.25.0", "@types/mocha": "^10.0.1", "@types/node": "^14.11.2", "@types/uuid": "^9.0.0", From 1b8a9282b68b8144b95961f4b6fd6ea0da4dfd6d Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 Oct 2023 12:42:48 +0800 Subject: [PATCH 18/64] fix tests --- packages/api/test/resolvers/integrations.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index bb9e56929..753024ecf 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -266,17 +266,6 @@ describe('Integrations resolvers', () => { expect(res.body.data.setIntegration.integration.enabled).to.be .true }) - - it('creates new cloud task to sync all existing articles and highlights', async () => { - const res = await graphqlRequest( - query(integrationId, integrationName, token, enabled), - authToken - ) - const integration = await findIntegration({ - id: res.body.data.setIntegration.integration.id, - }, loginUser.id) - expect(integration?.taskName).not.to.be.null - }) }) }) }) From dd1cb188e44140e5d53e379f19735b97ec80a414 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 Oct 2023 13:01:23 +0800 Subject: [PATCH 19/64] fix tests --- packages/import-handler/test/util.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/import-handler/test/util.ts b/packages/import-handler/test/util.ts index 54a8b5082..d90aaa92d 100644 --- a/packages/import-handler/test/util.ts +++ b/packages/import-handler/test/util.ts @@ -28,5 +28,6 @@ export const stubImportCtx = async (): Promise => { }, redisClient, taskId: '', + source: 'csv-importer', } } From d84aae1d8e6177f1be2bdfa2d51d64d1115246e6 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 27 Oct 2023 15:46:18 +0800 Subject: [PATCH 20/64] update Dockerfile --- packages/integration-handler/Dockerfile-exporter | 4 +--- packages/integration-handler/Dockerfile-importer | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/integration-handler/Dockerfile-exporter b/packages/integration-handler/Dockerfile-exporter index eb069aec9..60eee9d5f 100644 --- a/packages/integration-handler/Dockerfile-exporter +++ b/packages/integration-handler/Dockerfile-exporter @@ -1,6 +1,4 @@ -FROM node:18.16 - -RUN apt-get update && apt-get install -y g++ make python3 +FROM node:18.16-alpine # Run everything after as non-privileged user. WORKDIR /app diff --git a/packages/integration-handler/Dockerfile-importer b/packages/integration-handler/Dockerfile-importer index ef8abcb15..e3a790b8a 100644 --- a/packages/integration-handler/Dockerfile-importer +++ b/packages/integration-handler/Dockerfile-importer @@ -1,6 +1,4 @@ -FROM node:18.16 - -RUN apt-get update && apt-get install -y g++ make python3 +FROM node:18.16-alpine # Run everything after as non-privileged user. WORKDIR /app From 8b6dbf0604a689f994ea447df173a08ef3615f16 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 11:47:54 +0800 Subject: [PATCH 21/64] filter out archived items by default when importing from pocket --- packages/integration-handler/src/index.ts | 2 ++ .../src/integrations/integration.ts | 1 + .../src/integrations/pocket.ts | 22 +++++++++++++------ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts index d32ef8a08..4d739c8de 100644 --- a/packages/integration-handler/src/index.ts +++ b/packages/integration-handler/src/index.ts @@ -12,6 +12,7 @@ interface IntegrationRequest { integrationId: string syncAt: number // unix timestamp in milliseconds integrationName: string + includeArchived?: boolean } interface Claims { @@ -229,6 +230,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( token: claims.token, since, offset, + includeArchived: req.body.includeArchived, }) syncedAt = retrieved.since || Date.now() retrievedData = retrieved.data diff --git a/packages/integration-handler/src/integrations/integration.ts b/packages/integration-handler/src/integrations/integration.ts index 78b035db9..d501f88bd 100644 --- a/packages/integration-handler/src/integrations/integration.ts +++ b/packages/integration-handler/src/integrations/integration.ts @@ -16,6 +16,7 @@ export interface RetrieveRequest { since?: number // unix timestamp in milliseconds count?: number offset?: number + includeArchived?: boolean } export abstract class IntegrationClient { diff --git a/packages/integration-handler/src/integrations/pocket.ts b/packages/integration-handler/src/integrations/pocket.ts index fa4003c4d..1a68d4fe4 100644 --- a/packages/integration-handler/src/integrations/pocket.ts +++ b/packages/integration-handler/src/integrations/pocket.ts @@ -95,6 +95,7 @@ export class PocketClient extends IntegrationClient { since = 0, count = 100, offset = 0, + includeArchived = false, }: RetrieveRequest): Promise => { const pocketData = await this.retrievePocketData( token, @@ -112,13 +113,20 @@ export class PocketClient extends IntegrationClient { '1': 'ARCHIVED', '2': 'DELETED', } - const data = pocketItems.map((item) => ({ - url: item.given_url, - labels: item.tags - ? Object.values(item.tags).map((tag) => tag.tag) - : undefined, - state: statusToState[item.status], - })) + const data = pocketItems + .map((item) => ({ + url: item.given_url, + labels: item.tags + ? Object.values(item.tags).map((tag) => tag.tag) + : undefined, + state: statusToState[item.status], + })) + .filter((item) => { + if (item.state === 'DELETED') { + return false + } + return includeArchived || item.state !== 'ARCHIVED' + }) if (pocketData.error) { throw new Error(`Error retrieving pocket data: ${pocketData.error}`) From 761d0574a989169a201de4a6cc57fec3e1be5ddf Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 14:39:22 +0800 Subject: [PATCH 22/64] add state to the integration handler request payload --- packages/integration-handler/src/index.ts | 5 ++-- .../src/integrations/integration.ts | 9 ++++++- .../src/integrations/pocket.ts | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts index 4d739c8de..4b594d1f3 100644 --- a/packages/integration-handler/src/index.ts +++ b/packages/integration-handler/src/index.ts @@ -6,13 +6,14 @@ import * as jwt from 'jsonwebtoken' import { DateTime } from 'luxon' import { v4 as uuidv4 } from 'uuid' import { getIntegrationClient, updateIntegration } from './integrations' +import { State } from './integrations/integration' import { search } from './item' interface IntegrationRequest { integrationId: string syncAt: number // unix timestamp in milliseconds integrationName: string - includeArchived?: boolean + state?: State } interface Claims { @@ -230,7 +231,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( token: claims.token, since, offset, - includeArchived: req.body.includeArchived, + state: req.body.state, }) syncedAt = retrieved.since || Date.now() retrievedData = retrieved.data diff --git a/packages/integration-handler/src/integrations/integration.ts b/packages/integration-handler/src/integrations/integration.ts index d501f88bd..83a50fe80 100644 --- a/packages/integration-handler/src/integrations/integration.ts +++ b/packages/integration-handler/src/integrations/integration.ts @@ -1,5 +1,12 @@ import { Item } from '../item' +export enum State { + ARCHIVED = 'ARCHIVED', + UNREAD = 'UNREAD', + UNARCHIVED = 'UNARCHIVED', + ALL = 'ALL', +} + export interface RetrievedData { url: string labels?: string[] @@ -16,7 +23,7 @@ export interface RetrieveRequest { since?: number // unix timestamp in milliseconds count?: number offset?: number - includeArchived?: boolean + state?: State } export abstract class IntegrationClient { diff --git a/packages/integration-handler/src/integrations/pocket.ts b/packages/integration-handler/src/integrations/pocket.ts index 1a68d4fe4..a8720cf1d 100644 --- a/packages/integration-handler/src/integrations/pocket.ts +++ b/packages/integration-handler/src/integrations/pocket.ts @@ -3,6 +3,7 @@ import { IntegrationClient, RetrievedResult, RetrieveRequest, + State, } from './integration' interface PocketResponse { @@ -60,7 +61,8 @@ export class PocketClient extends IntegrationClient { accessToken: string, since: number, // unix timestamp in seconds count = 100, - offset = 0 + offset = 0, + state = 'all' ): Promise => { const url = `${this.apiUrl}/get` try { @@ -69,7 +71,7 @@ export class PocketClient extends IntegrationClient { { consumer_key: process.env.POCKET_CONSUMER_KEY, access_token: accessToken, - state: 'all', + state, detailType: 'complete', since, sort: 'oldest', @@ -95,13 +97,25 @@ export class PocketClient extends IntegrationClient { since = 0, count = 100, offset = 0, - includeArchived = false, + state = State.UNARCHIVED, }: RetrieveRequest): Promise => { + let pocketItemState = 'all' + + switch (state) { + case State.ARCHIVED: + pocketItemState = 'archive' + break + case State.UNREAD: + pocketItemState = 'unread' + break + } + const pocketData = await this.retrievePocketData( token, since / 1000, count, - offset + offset, + pocketItemState ) if (!pocketData) { throw new Error('Error retrieving pocket data') @@ -125,7 +139,8 @@ export class PocketClient extends IntegrationClient { if (item.state === 'DELETED') { return false } - return includeArchived || item.state !== 'ARCHIVED' + + return state !== State.UNARCHIVED || item.state !== 'ARCHIVED' }) if (pocketData.error) { From 9be25fa418539ee521443edc2c5847daae398418 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 15:07:52 +0800 Subject: [PATCH 23/64] add import_item_state column to integrations table --- ...4.do.add_import_item_state_to_integration.sql | 16 ++++++++++++++++ ...undo.add_import_item_state_to_integration.sql | 11 +++++++++++ 2 files changed, 27 insertions(+) create mode 100755 packages/db/migrations/0144.do.add_import_item_state_to_integration.sql create mode 100755 packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql diff --git a/packages/db/migrations/0144.do.add_import_item_state_to_integration.sql b/packages/db/migrations/0144.do.add_import_item_state_to_integration.sql new file mode 100755 index 000000000..f7979cd80 --- /dev/null +++ b/packages/db/migrations/0144.do.add_import_item_state_to_integration.sql @@ -0,0 +1,16 @@ +-- Type: DO +-- Name: add_import_item_state_to_integration +-- Description: Add import_item_state column to integration table + +BEGIN; + +CREATE type import_item_state_type AS ENUM ( + 'UNREAD', + 'UNARCHIVED', + 'ARCHIVED', + 'ALL' +); + +ALTER TABLE omnivore.integrations ADD COLUMN import_item_state import_item_state_type; + +COMMIT; diff --git a/packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql b/packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql new file mode 100755 index 000000000..b64ce4f89 --- /dev/null +++ b/packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql @@ -0,0 +1,11 @@ +-- Type: UNDO +-- Name: add_import_item_state_to_integration +-- Description: Add import_item_state column to integration table + +BEGIN; + +ALTER TABLE omnivore.integrations DROP COLUMN IF EXISTS import_item_state; + +DROP TYPE IF EXISTS import_item_state_type; + +COMMIT; From a52869212568f216c7b8c4cc489adb43b717d96b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 15:12:18 +0800 Subject: [PATCH 24/64] add import_item_state to integration entity --- packages/api/src/entity/integration.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index 8f03d455a..72855e7dc 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -14,6 +14,13 @@ export enum IntegrationType { Import = 'IMPORT', } +export enum ImportItemState { + UNREAD = 'UNREAD', + UNARCHIVED = 'UNARCHIVED', + ARCHIVED = 'ARCHIVED', + ALL = 'ALL', +} + @Entity({ name: 'integrations' }) export class Integration { @PrimaryGeneratedColumn('uuid') @@ -49,4 +56,7 @@ export class Integration { @Column('text', { nullable: true }) taskName?: string | null + + @Column('enum', { enum: ImportItemState, nullable: true }) + importItemState?: ImportItemState | null } From 28393974fb0d657dcb2384096a2050325dd10a45 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 15:33:26 +0800 Subject: [PATCH 25/64] Filter out inactive subscriptions on iOS --- apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift index bc381d013..00adc5c4a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift @@ -14,7 +14,7 @@ import Views isLoading = true do { - subscriptions = try await dataService.subscriptions() + subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active } } catch { hasNetworkError = true } From ba5ad51e6a126fb64653d03d972576b125b6b2c1 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 15:34:25 +0800 Subject: [PATCH 26/64] Set intercom user hash --- .../Sources/App/Views/LibraryTabView.swift | 1 + .../App/Views/Profile/ProfileView.swift | 24 +++++----- .../Sources/App/Views/RootView/RootView.swift | 1 + .../App/Views/RootView/RootViewModel.swift | 3 ++ .../Services/DataService/DataService.swift | 15 +----- .../DataService/Queries/ViewerFetcher.swift | 46 +++++++++++-------- apple/Sources/MainApp.swift | 1 + 7 files changed, 46 insertions(+), 45 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 9e0739417..763bef4f6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -12,6 +12,7 @@ import Services import SwiftUI import Views +@MainActor struct LibraryTabView: View { @EnvironmentObject var dataService: DataService diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index e47f3682e..3d46b8672 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -18,17 +18,15 @@ import Views } func loadProfileData(dataService: DataService) async { - if let currentViewer = dataService.currentViewer { - loadProfileCardData(viewer: currentViewer) - return + if let currentViewer = dataService.currentViewer, + let name = currentViewer.name, + let username = currentViewer.username + { + loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL) } - guard let viewerObjectID = try? await dataService.fetchViewer() else { return } - - await dataService.viewContext.perform { - if let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer { - self.loadProfileCardData(viewer: viewer) - } + if let viewer = try? await dataService.fetchViewer() { + loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL) } } @@ -47,11 +45,11 @@ import Views } } - private func loadProfileCardData(viewer: Viewer) { + private func loadProfileCardData(name: String, username: String, profileImageURL: String?) { profileCardData = ProfileCardData( - name: viewer.unwrappedName, - username: viewer.unwrappedUsername, - imageURL: viewer.profileImageURL.flatMap { URL(string: $0) } + name: name, + username: username, + imageURL: profileImageURL.flatMap { URL(string: $0) } ) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index abff913d1..c48252a58 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -16,6 +16,7 @@ public struct RootView: View { if let intercomProvider = intercomProvider { DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger DataService.registerIntercomUser = intercomProvider.registerIntercomUser + DataService.setIntercomUserHash = intercomProvider.setIntercomUserHash Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 24796a89f..2865d1887 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -43,15 +43,18 @@ public final class RootViewModel: ObservableObject { public struct IntercomProvider { public init( registerIntercomUser: @escaping (String) -> Void, + setIntercomUserHash: @escaping (String) -> Void, unregisterIntercomUser: @escaping () -> Void, showIntercomMessenger: @escaping () -> Void ) { self.registerIntercomUser = registerIntercomUser + self.setIntercomUserHash = setIntercomUserHash self.unregisterIntercomUser = unregisterIntercomUser self.showIntercomMessenger = showIntercomMessenger } public let registerIntercomUser: (String) -> Void + public let setIntercomUserHash: (String) -> Void public let unregisterIntercomUser: () -> Void public let showIntercomMessenger: () -> Void } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 73f017d58..880a88a44 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -17,6 +17,8 @@ let logger = Logger(subsystem: "app.omnivore", category: "data-service") public final class DataService: ObservableObject { public static var registerIntercomUser: ((String) -> Void)? + public static var setIntercomUserHash: ((String) -> Void)? + public static var showIntercomMessenger: (() -> Void)? public let appEnvironment: AppEnvironment @@ -96,19 +98,6 @@ public final class DataService: ObservableObject { return try? persistentContainer.viewContext.fetch(fetchRequest).first } - public func username() async -> String? { - if let cachedUsername = currentViewer?.username { - return cachedUsername - } - - if let viewerObjectID = try? await fetchViewer() { - let viewer = backgroundContext.object(with: viewerObjectID) as? Viewer - return viewer?.unwrappedUsername - } - - return nil - } - public func switchAppEnvironment(appEnvironment: AppEnvironment) { do { try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift index 28f8668d2..78117f8a0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift @@ -5,7 +5,8 @@ import SwiftGraphQL import Utils public extension DataService { - func fetchViewer() async throws -> NSManagedObjectID { + @MainActor + func fetchViewer() async throws -> ViewerInternal? { let selection = Selection { ViewerInternal( userID: try $0.id(), @@ -15,7 +16,8 @@ public extension DataService { name: try $0.name(), profileImageURL: try $0.profile( selection: .init { try $0.pictureUrl() } - ) + ), + intercomHash: try $0.intercomHash() ) } @@ -29,15 +31,24 @@ public extension DataService { return try await withCheckedThrowingContinuation { continuation in send(query, to: path, headers: headers) { [weak self] result in switch result { - case let .success(payload): + case let .success(payload: payload): if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil { UserDefaults.standard.setValue(payload.data.userID, forKey: Keys.userIdKey) DataService.registerIntercomUser?(payload.data.userID) } - if let self = self, let viewerID = payload.data.persist(context: self.backgroundContext) { - continuation.resume(returning: viewerID) - } else { + do { + if let intercomUserHash = payload.data.intercomHash { + DataService.setIntercomUserHash?(intercomUserHash) + } + + if let self = self { + try payload.data.persist(context: self.backgroundContext) + continuation.resume(returning: payload.data) + } else { + continuation.resume(throwing: BasicError.message(messageText: "no self found")) + } + } catch { continuation.resume(throwing: BasicError.message(messageText: "coredata error")) } case .failure: @@ -48,16 +59,15 @@ public extension DataService { } } -private struct ViewerInternal { - let userID: String - let username: String - let name: String - let profileImageURL: String? +public struct ViewerInternal { + public let userID: String + public let username: String + public let name: String + public let profileImageURL: String? + public let intercomHash: String? - func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { - var objectID: NSManagedObjectID? - - context.performAndWait { + func persist(context: NSManagedObjectContext) throws { + try context.performAndWait { let viewer = Viewer(context: context) viewer.userID = userID viewer.username = username @@ -66,15 +76,13 @@ private struct ViewerInternal { do { try context.save() + EventTracker.registerUser(userID: userID) logger.debug("Viewer saved succesfully") - objectID = viewer.objectID - EventTracker.registerUser(userID: viewer.unwrappedUserID) } catch { context.rollback() logger.debug("Failed to save Viewer: \(error.localizedDescription)") + throw error } } - - return objectID } } diff --git a/apple/Sources/MainApp.swift b/apple/Sources/MainApp.swift index a5dc0dc0e..8f7151a84 100644 --- a/apple/Sources/MainApp.swift +++ b/apple/Sources/MainApp.swift @@ -30,6 +30,7 @@ struct MainApp: App { RootView( intercomProvider: AppKeys.sharedInstance?.intercom != nil ? IntercomProvider( registerIntercomUser: { Intercom.registerUser(withUserId: $0) }, + setIntercomUserHash: { Intercom.setUserHash($0) }, unregisterIntercomUser: Intercom.logout, showIntercomMessenger: Intercom.presentMessenger ) : nil From 77c69043bc16950c39c7ef49951a931b9281aa10 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 15:40:58 +0800 Subject: [PATCH 27/64] Remove some old code --- .../Share/Views/ShareExtensionView.swift | 271 +----------------- 1 file changed, 1 insertion(+), 270 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index a8ff83677..5d46fea5c 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -34,35 +34,12 @@ public struct ShareExtensionView: View { public init(viewModel: ShareExtensionViewModel, labelsViewModel: LabelsViewModel, - extensionContext: NSExtensionContext?) - { + extensionContext: NSExtensionContext?) { _viewModel = StateObject(wrappedValue: viewModel) _labelsViewModel = StateObject(wrappedValue: labelsViewModel) self.extensionContext = extensionContext } - private var titleText: String { - switch viewModel.status { - case .saved, .synced, .syncFailed(error: _): - return "Saved to Omnivore" - case .processing: - return "Saving to Omnivore" - case .failed(error: _): - return "Error saving to Omnivore" - } - } - - private var titleColor: Color { - switch viewModel.status { - case .saved, .processing: - return .appGrayText - case .failed(error: _), .syncFailed(error: _): - return .red - case .synced: - return .appGreenSuccess - } - } - private func localImage(from url: URL) -> Image? { #if os(iOS) if let data = try? Data(contentsOf: url), let img = UIImage(data: data) { @@ -85,252 +62,6 @@ public struct ShareExtensionView: View { } } -// var titleBar: some View { -// HStack { -// Spacer() -// -// Image(systemName: "checkmark.circle") -// .frame(width: 15, height: 15) -// .foregroundColor(.appGreenSuccess) -// .opacity(isSynced ? 1.0 : 0.0) -// -// Text(messageText ?? titleText) -// .font(.appSubheadline) -// .foregroundColor(titleColor) -// -// Spacer() -// } -// } - - public var titleBox: some View { - VStack(alignment: .trailing) { - Button(action: {}, label: { - Text("Edit") - .font(.appFootnote) - .padding(.trailing, 8) - .onTapGesture { - viewState = .editingTitle - } - }) - .disabled(viewState == .editingTitle) - .opacity(viewState == .editingTitle ? 0.0 : 1.0) - - VStack(alignment: .leading) { - if viewState != .editingTitle { - Text(self.viewModel.title) - .font(.appSubheadline) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.appGrayTextContrast) - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer() - - Text(self.viewModel.url ?? "") - .font(.appFootnote) - .foregroundColor(.appGrayText) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, maxHeight: 60) - .padding() - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.appGrayBorder, lineWidth: 1) - ) - } - } - -// var labelsSection: some View { -// HStack { -// if viewState != .editingLabels { -// ZStack { -// Circle() -// .foregroundColor(Color.blue) -// .frame(width: 34, height: 34) -// -// Image(systemName: "tag") -// .font(.appCallout) -// .frame(width: 34, height: 34) -// } -// .padding(.trailing, 8) -// -// VStack { -// Text(LocalText.labelsGeneric) -// .font(.appSubheadline) -// .foregroundColor(Color.appGrayTextContrast) -// .frame(maxWidth: .infinity, alignment: .leading) -// -// let labelCount = labelsViewModel.selectedLabels.count -// Text(labelCount > 0 ? -// "\(labelCount) label\(labelCount > 1 ? "s" : "") selected" -// : "Add labels to your saved link") -// .font(.appFootnote) -// .foregroundColor(Color.appGrayText) -// .frame(maxWidth: .infinity, alignment: .leading) -// } -// -// Spacer() -// -// Image(systemName: "chevron.right") -// .font(.appCallout) -// } else { -// VStack(spacing: 15) { -// SearchBar(searchTerm: $labelsViewModel.labelSearchFilter) -// -// // swiftlint:disable line_length -// ScrollView { -// LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), -// selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), -// onLabelTap: onLabelTap) -// Button( -// action: { labelsViewModel.showCreateLabelModal = true }, -// label: { -// HStack { -// let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines) -// Image(systemName: "tag").foregroundColor(.blue) -// Text( -// labelsViewModel.labelSearchFilter.count > 0 ? -// "Create: \"\(trimmedLabelName)\" label" : -// LocalText.createLabelMessage -// ).foregroundColor(.blue) -// .font(Font.system(size: 14)) -// Spacer() -// } -// } -// ) -// .buttonStyle(PlainButtonStyle()) -// .padding(10) -// }.background(Color.appButtonBackground) -// // swiftlint:enable line_length -// } -// } -// } -// .padding(viewState == .editingLabels ? 0 : 16) -// .background(Color.extensionBackground) -// .frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60) -// .cornerRadius(8) -// } - - var highlightSection: some View { - HStack { - if viewState != .viewingHighlight { - ZStack { - Circle() - .foregroundColor(Color.appBackground) - .frame(width: 34, height: 34) - - Image(systemName: "highlighter") - .font(.appCallout) - .frame(width: 34, height: 34) - .foregroundColor(Color.black) - } - .padding(.trailing, 8) - - VStack { - Text(LocalText.genericHighlight) - .font(.appSubheadline) - .foregroundColor(Color.appGrayTextContrast) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(viewModel.highlightData != nil ? - viewModel.highlightData!.highlightText - : "Select text before saving to create highlight") - .font(.appFootnote) - .foregroundColor(Color.appGrayText) - .frame(maxWidth: .infinity, alignment: .leading) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.appCallout) - } else if let highlightText = self.viewModel.highlightData?.highlightText { - Text(highlightText) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .cornerRadius(8) - .padding(0) - } - } - .padding(16) - .frame(maxWidth: .infinity, maxHeight: viewState == .viewingHighlight ? .infinity : 60) - .background(Color.appButtonBackground) - .cornerRadius(8) - } - - var moreActionsMenu: some View { - Menu { - Button(action: {}, label: { - Label( - "Edit Info", - systemImage: "info.circle" - ) - }) - Button(action: { - if let linkedItem = self.viewModel.linkedItem { - self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService, - objectID: linkedItem.objectID, - archived: true) - messageText = "Link Archived" - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } - } - }, label: { - Label( - "Archive", - systemImage: "archivebox" - ) - }) - Button( - action: { - if let linkedItem = self.viewModel.linkedItem { - self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID) - messageText = "Link Removed" - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - } - } - }, - label: { - Label("Remove", systemImage: "trash") - } - ) - } label: { - Text("More Actions") - .font(.appFootnote) - .foregroundColor(Color.blue) - .frame(maxWidth: .infinity) - .padding(8) - .padding(.bottom, 8) - } - } - - var editingViewTitle: String { - switch viewState { - case .editingTitle: - return "Edit Title" - case .editingLabels: - return LocalText.labelsGeneric - case .viewingHighlight: - return LocalText.genericHighlight - default: - return "" - } - } - - func submitEditTitle() { - if viewState == .editingTitle { - if let linkedItem = viewModel.linkedItem { - viewModel.submitTitleEdit(dataService: viewModel.services.dataService, - itemID: linkedItem.unwrappedID, - title: viewModel.title, - description: linkedItem.descriptionText ?? "") - } - } - viewState = .mainView - } - var articleInfoBox: some View { HStack(alignment: .top, spacing: 15) { AsyncImage(url: self.viewModel.iconURL) { phase in From f6c0b2ba347a1abf478c2e67e8973b1c4bdf0ba9 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 16:09:10 +0800 Subject: [PATCH 28/64] add import_item_state to set integration api payload --- packages/api/src/entity/integration.ts | 8 ++++---- packages/api/src/generated/graphql.ts | 9 +++++++++ packages/api/src/generated/schema.graphql | 8 ++++++++ packages/api/src/resolvers/integrations/index.ts | 10 +++++++++- packages/api/src/schema.ts | 8 ++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index 72855e7dc..e446f10c3 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -15,10 +15,10 @@ export enum IntegrationType { } export enum ImportItemState { - UNREAD = 'UNREAD', - UNARCHIVED = 'UNARCHIVED', - ARCHIVED = 'ARCHIVED', - ALL = 'ALL', + Unread = 'UNREAD', + Unarchived = 'UNARCHIVED', + Archived = 'ARCHIVED', + All = 'ALL', } @Entity({ name: 'integrations' }) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index e42282815..8041b6beb 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -970,6 +970,13 @@ export type ImportFromIntegrationSuccess = { success: Scalars['Boolean']; }; +export enum ImportItemState { + All = 'ALL', + Archived = 'ARCHIVED', + Unarchived = 'UNARCHIVED', + Unread = 'UNREAD' +} + export type Integration = { __typename?: 'Integration'; createdAt: Scalars['Date']; @@ -2375,6 +2382,7 @@ export enum SetIntegrationErrorCode { export type SetIntegrationInput = { enabled: Scalars['Boolean']; id?: InputMaybe; + importItemState?: InputMaybe; name: Scalars['String']; syncedAt?: InputMaybe; token: Scalars['String']; @@ -3495,6 +3503,7 @@ export type ResolversTypes = { ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode; ImportFromIntegrationResult: ResolversTypes['ImportFromIntegrationError'] | ResolversTypes['ImportFromIntegrationSuccess']; ImportFromIntegrationSuccess: ResolverTypeWrapper; + ImportItemState: ImportItemState; Int: ResolverTypeWrapper; Integration: ResolverTypeWrapper; IntegrationType: IntegrationType; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index dfb7c1677..335c93409 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -863,6 +863,13 @@ type ImportFromIntegrationSuccess { success: Boolean! } +enum ImportItemState { + ALL + ARCHIVED + UNARCHIVED + UNREAD +} + type Integration { createdAt: Date! enabled: Boolean! @@ -1834,6 +1841,7 @@ enum SetIntegrationErrorCode { input SetIntegrationInput { enabled: Boolean! id: ID + importItemState: ImportItemState name: String! syncedAt: Date token: String! diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 82a67e6b5..fe9b9c33d 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -1,5 +1,9 @@ import { DeepPartial } from 'typeorm' -import { Integration, IntegrationType } from '../../entity/integration' +import { + ImportItemState, + Integration, + IntegrationType, +} from '../../entity/integration' import { env } from '../../env' import { DeleteIntegrationError, @@ -47,6 +51,10 @@ export const setIntegrationResolver = authorized< id: input.id || undefined, type: input.type || IntegrationType.Export, syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined, + importItemState: + input.type === IntegrationType.Import + ? input.importItemState ?? ImportItemState.Unarchived // default to unarchived + : undefined, } if (input.id) { // Update diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index d257b5069..c6bb63f9d 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1972,6 +1972,13 @@ const schema = gql` ALREADY_EXISTS } + enum ImportItemState { + UNREAD + UNARCHIVED + ARCHIVED + ALL + } + input SetIntegrationInput { id: ID name: String! @@ -1979,6 +1986,7 @@ const schema = gql` token: String! enabled: Boolean! syncedAt: Date + importItemState: ImportItemState } union IntegrationsResult = IntegrationsSuccess | IntegrationsError From 8df6fb3ae2c6909895d85fe3307b048461a212e5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 30 Oct 2023 16:22:09 +0800 Subject: [PATCH 29/64] import unarchived items by default --- packages/api/src/resolvers/integrations/index.ts | 5 +++-- packages/api/src/utils/createTask.ts | 5 ++++- packages/integration-handler/src/index.ts | 4 +++- packages/integration-handler/src/integrations/integration.ts | 2 +- packages/integration-handler/src/integrations/pocket.ts | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index fe9b9c33d..dc2f861c1 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -53,7 +53,7 @@ export const setIntegrationResolver = authorized< syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined, importItemState: input.type === IntegrationType.Import - ? input.importItemState ?? ImportItemState.Unarchived // default to unarchived + ? input.importItemState || ImportItemState.Unarchived // default to unarchived : undefined, } if (input.id) { @@ -242,7 +242,8 @@ export const importFromIntegrationResolver = authorized< integration.id, integration.name, integration.syncedAt?.getTime() || 0, - authToken + authToken, + integration.importItemState || ImportItemState.Unarchived ) // update task name in integration await updateIntegration(integration.id, { taskName }, uid) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index be71a64a3..643772116 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -6,6 +6,7 @@ import { google } from '@google-cloud/tasks/build/protos/protos' import axios from 'axios' import { nanoid } from 'nanoid' import { DeepPartial } from 'typeorm' +import { ImportItemState } from '../entity/integration' import { Recommendation } from '../entity/recommendation' import { env } from '../env' import { @@ -459,13 +460,15 @@ export const enqueueImportFromIntegration = async ( integrationId: string, integrationName: string, syncAt: number, // unix timestamp in milliseconds - authToken: string + authToken: string, + state: ImportItemState ): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { integrationId, integrationName, syncAt, + state, } const headers = { diff --git a/packages/integration-handler/src/index.ts b/packages/integration-handler/src/index.ts index 4b594d1f3..dabccce5e 100644 --- a/packages/integration-handler/src/index.ts +++ b/packages/integration-handler/src/index.ts @@ -187,6 +187,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( let offset = 0 let syncedAt = req.body.syncAt const since = syncedAt + const state = req.body.state || State.UNARCHIVED // default to unarchived console.log('importing pages from integration...') // get pages from integration @@ -194,6 +195,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( token: claims.token, since, offset, + state, }) syncedAt = retrieved.since || Date.now() @@ -231,7 +233,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction( token: claims.token, since, offset, - state: req.body.state, + state, }) syncedAt = retrieved.since || Date.now() retrievedData = retrieved.data diff --git a/packages/integration-handler/src/integrations/integration.ts b/packages/integration-handler/src/integrations/integration.ts index 83a50fe80..40fbd38f7 100644 --- a/packages/integration-handler/src/integrations/integration.ts +++ b/packages/integration-handler/src/integrations/integration.ts @@ -23,7 +23,7 @@ export interface RetrieveRequest { since?: number // unix timestamp in milliseconds count?: number offset?: number - state?: State + state: State } export abstract class IntegrationClient { diff --git a/packages/integration-handler/src/integrations/pocket.ts b/packages/integration-handler/src/integrations/pocket.ts index a8720cf1d..e2f911717 100644 --- a/packages/integration-handler/src/integrations/pocket.ts +++ b/packages/integration-handler/src/integrations/pocket.ts @@ -97,7 +97,7 @@ export class PocketClient extends IntegrationClient { since = 0, count = 100, offset = 0, - state = State.UNARCHIVED, + state, }: RetrieveRequest): Promise => { let pocketItemState = 'all' From 27632c6924110d6b9401b349bbd0217e3406e3b8 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 18:44:13 +0800 Subject: [PATCH 30/64] Some scaffolding for multiselect --- .../Share/Views/ShareExtensionView.swift | 3 +- .../Components/FeedCardNavigationLink.swift | 2 +- .../App/Views/Home/HomeFeedViewIOS.swift | 152 ++++++++++-------- .../App/Views/Home/HomeFeedViewModel.swift | 1 + .../Views/FeedItem/LibraryItemCard.swift | 15 ++ 5 files changed, 107 insertions(+), 66 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 5d46fea5c..715db6ea5 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -34,7 +34,8 @@ public struct ShareExtensionView: View { public init(viewModel: ShareExtensionViewModel, labelsViewModel: LabelsViewModel, - extensionContext: NSExtensionContext?) { + extensionContext: NSExtensionContext?) + { _viewModel = StateObject(wrappedValue: viewModel) _labelsViewModel = StateObject(wrappedValue: labelsViewModel) self.extensionContext = extensionContext diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 37865d853..ad02df3d8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -32,7 +32,7 @@ struct FeedCardNavigationLink: View { @EnvironmentObject var audioController: AudioController let item: LinkedItem - + let isInMultiSelectMode: Bool @ObservedObject var viewModel: HomeFeedViewModel var body: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index dba244997..7a79983f4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -86,70 +86,7 @@ struct AnimatingCellHeight: AnimatableModifier { } // .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .barLeading) { - VStack(alignment: .leading) { - let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName - - Text(title) - .font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold)) - - if prefersListLayout, isListScrolled { - Text(listTitle) - .font(Font.system(size: 15, weight: .regular)) - .foregroundColor(Color.appGrayText) - } - }.frame(maxWidth: .infinity, alignment: .leading) - } - ToolbarItem(placement: .barTrailing) { - Button("", action: {}) - .disabled(true) - .overlay { - if viewModel.isLoading, !prefersListLayout, enableGrid { - ProgressView() - } - } - } - ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) { - if enableGrid { - Button( - action: { prefersListLayout.toggle() }, - label: { - Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") - } - ) - } else { - EmptyView() - } - } - ToolbarItem(placement: .barTrailing) { - Button( - action: { searchPresented = true }, - label: { - Image(systemName: "magnifyingglass") - .resizable() - .frame(width: 18, height: 18) - .padding(.vertical) - .foregroundColor(.appGrayTextContrast) - } - ) - } - ToolbarItem(placement: .barTrailing) { - if UIDevice.isIPhone { - Menu(content: { - Button(action: { settingsPresented = true }, label: { - Label(LocalText.genericProfile, systemImage: "person.circle") - }) - Button(action: { addLinkPresented = true }, label: { - Label("Add Link", systemImage: "plus.circle") - }) - }, label: { - Image.utilityMenu - }) - .foregroundColor(.appGrayTextContrast) - } else { - EmptyView() - } - } + toolbarItems } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in loadItems(isRefresh: false) @@ -215,6 +152,92 @@ struct AnimatingCellHeight: AnimatableModifier { } } } + + var toolbarItems: some ToolbarContent { + Group { + ToolbarItem(placement: .barLeading) { + VStack(alignment: .leading) { + let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName + + Text(title) + .font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold)) + + if prefersListLayout, isListScrolled { + Text(listTitle) + .font(Font.system(size: 15, weight: .regular)) + .foregroundColor(Color.appGrayText) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + ToolbarItem(placement: .barTrailing) { + Button("", action: {}) + .disabled(true) + .overlay { + if viewModel.isLoading, !prefersListLayout, enableGrid { + ProgressView() + } + } + } + ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) { + if enableGrid { + Button( + action: { prefersListLayout.toggle() }, + label: { + Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") + } + ) + } else { + EmptyView() + } + } + ToolbarItem(placement: .barTrailing) { + Button( + action: { searchPresented = true }, + label: { + Image(systemName: "magnifyingglass") + .resizable() + .frame(width: 18, height: 18) + .padding(.vertical) + .foregroundColor(.appGrayTextContrast) + } + ) + } + ToolbarItem(placement: .barTrailing) { + if UIDevice.isIPhone { + Menu(content: { +// Button(action: { +// // withAnimation { +// viewModel.isInMultiSelectMode.toggle() +// // } +// }, label: { +// Label(viewModel.isInMultiSelectMode ? "End Multiselect" : "Select Multiple", systemImage: "checkmark.circle") +// }) + Button(action: { addLinkPresented = true }, label: { + Label("Add Link", systemImage: "plus.circle") + }) + Button(action: { settingsPresented = true }, label: { + Label(LocalText.genericProfile, systemImage: "person.circle") + }) + + }, label: { + Image.utilityMenu + }) + .foregroundColor(.appGrayTextContrast) + } else { + EmptyView() + } + } +// if viewModel.isInMultiSelectMode { +// ToolbarItemGroup(placement: .bottomBar) { +// Button(action: {}, label: { Image(systemName: "archivebox") }) +// Button(action: {}, label: { Image(systemName: "trash") }) +// Button(action: {}, label: { Image.label }) +// Spacer() +// Button(action: { viewModel.isInMultiSelectMode = false }, label: { Text("Cancel") }) +// } +// } + } + } } @MainActor @@ -531,6 +554,7 @@ struct AnimatingCellHeight: AnimatableModifier { ForEach(viewModel.items) { item in FeedCardNavigationLink( item: item, + isInMultiSelectMode: viewModel.isInMultiSelectMode, viewModel: viewModel ) .background(GeometryReader { geometry in diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index c39745961..d1ba61cbc 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -24,6 +24,7 @@ import Views @Published var itemToSnoozeID: String? @Published var linkRequest: LinkRequest? @Published var showLoadingBar = false + @Published var isInMultiSelectMode = false @Published var appliedSort = LinkedItemSort.newest.rawValue @Published var selectedLinkItem: NSManagedObjectID? // used by mac app only diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index 83350cd70..47b41f3cf 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -281,3 +281,18 @@ public struct LibraryItemCard: View { LabelsFlowLayout(labels: nonFlairLabels) } } + +struct CircleCheckboxToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button(action: { + configuration.isOn.toggle() + }, label: { + HStack { + Image(systemName: configuration.isOn ? "checkmark.circle" : "circle") + .font(Font.system(size: 18)) + .foregroundColor(configuration.isOn ? Color.blue : Color.appGrayTextContrast) + } + }) + .buttonStyle(.plain) + } +} From 57c481ccfdf1ef913c16577c29f29cb4f9f24f19 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 30 Oct 2023 19:16:47 +0800 Subject: [PATCH 31/64] Add editinfo to share extension --- .../Share/Views/AddNoteSheet.swift | 2 +- .../Share/Views/EditInfoSheet.swift | 50 +++++++++++++++++++ .../Share/Views/ShareExtensionView.swift | 4 +- .../Views/LinkedItemMetadataEditView.swift | 6 +-- .../ShareExtensionViewController.swift | 8 +++ 5 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift index 6a613d386..335f53cc2 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift @@ -48,7 +48,7 @@ public struct AddNoteSheet: View { .task { self.focusedField = .noteEditor } - .background(Color.extensionPanelBackground) + .background(Color.extensionBackground) .navigationTitle("Add Note") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(leading: Button(action: { diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift new file mode 100644 index 000000000..45dea726b --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift @@ -0,0 +1,50 @@ +// +// EditInfoSheet.swift +// +// +// Created by Jackson Harper on 10/30/23. +// + +import Models +import Services +import SwiftUI +import Utils +import Views + +public struct EditInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + @StateObject var viewModel: ShareExtensionViewModel + let highlightId = UUID().uuidString.lowercased() + let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8) + + enum FocusField: Hashable { + case noteEditor + } + + @FocusState private var focusedField: FocusField? + + public init(viewModel: ShareExtensionViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4) + } + +// func saveInfo() { +// if let linkedItem = viewModel.linkedItem { +// _ = viewModel.services.dataService.updateLinkedItemTitleAndDescription(itemID: linkedItem.unwrappedID, title: title, description: description, author: author) +// } else { +// // Maybe we shouldn't even allow this UI without linkeditem existing +// } +// } + + public var body: some View { + if let item = viewModel.linkedItem { + LinkedItemMetadataEditView(item: item) { title, _ in + viewModel.title = title + } + .environmentObject(viewModel.services.dataService) + } else { + ProgressView() + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 715db6ea5..8954e26b1 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -159,7 +159,9 @@ public struct ShareExtensionView: View { var moreMenuButton: some View { Menu { - Button(action: {}, label: { + Button(action: { + NotificationCenter.default.post(name: Notification.Name("ShowEditInfoSheet"), object: nil) + }, label: { Label( "Edit Info", systemImage: "info.circle" diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift index d7f81aef7..6258fee2f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift @@ -95,13 +95,13 @@ struct LinkedItemMetadataEditView: View { var iOSBody: some View { NavigationView { editForm - .navigationTitle("Edit Title and Description") + .navigationTitle("Edit Info") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .barLeading) { Button( action: { presentationMode.wrappedValue.dismiss() }, - label: { Text(LocalText.cancelGeneric).foregroundColor(.appGrayTextContrast) } + label: { Text(LocalText.cancelGeneric) } ) } ToolbarItem(placement: .barTrailing) { @@ -113,7 +113,7 @@ struct LinkedItemMetadataEditView: View { } presentationMode.wrappedValue.dismiss() }, - label: { Text(LocalText.genericSave).foregroundColor(.appGrayTextContrast) } + label: { Text(LocalText.genericSave).bold() } ) } } diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index f72d5798f..73c8f6242 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -32,6 +32,14 @@ import Views self.openSheet(AnyView(EditLabelsSheet(viewModel: self.viewModel, labelsViewModel: self.labelsViewModel))) } + NotificationCenter.default.addObserver( + forName: Notification.Name("ShowEditInfoSheet"), + object: nil, + queue: OperationQueue.main + ) { _ in + self.openSheet(AnyView(EditInfoSheet(viewModel: self.viewModel))) + } + embed( childViewController: UIViewController.makeShareExtensionController( viewModel: viewModel, From 2b78827964f67ebe80a997402c758d7f12eb5e0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 21:04:50 +0000 Subject: [PATCH 32/64] Bump yet-another-react-lightbox from 3.12.2 to 3.14.0 Bumps [yet-another-react-lightbox](https://github.com/igordanchenko/yet-another-react-lightbox) from 3.12.2 to 3.14.0. - [Release notes](https://github.com/igordanchenko/yet-another-react-lightbox/releases) - [Changelog](https://github.com/igordanchenko/yet-another-react-lightbox/blob/main/CHANGELOG.md) - [Commits](https://github.com/igordanchenko/yet-another-react-lightbox/compare/v3.12.2...v3.14.0) --- updated-dependencies: - dependency-name: yet-another-react-lightbox dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 35141c0fe..03e3c44c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29011,9 +29011,9 @@ yauzl@^2.10.0: fd-slicer "~1.1.0" yet-another-react-lightbox@^3.12.0: - version "3.12.2" - resolved "https://registry.yarnpkg.com/yet-another-react-lightbox/-/yet-another-react-lightbox-3.12.2.tgz#10b6a023fe6469e51d52e881715f00d5f2e5e82b" - integrity sha512-sFypE74Rjjl7WbzKSrGeZ7lbcgduOam/BKj8kqzx/ciC4dJyYdE1SG0dur9oDq/gZiY3VGJh+PYGbq6ZwmPFmA== + version "3.14.0" + resolved "https://registry.yarnpkg.com/yet-another-react-lightbox/-/yet-another-react-lightbox-3.14.0.tgz#7f90a345818e252c3963d02ba0289e36b968cfce" + integrity sha512-UbkxGre2TzzIuRL9bQp6Spbc/9Hgn8JYofs0DJL1cyCc6+ir5I5djJKLs9UpyH61Ak/y81QYF5MA6ve//ggJrw== yn@3.1.1: version "3.1.1" From 3be2db1113857d9c4a601b41eb5bfe7cda9b23d7 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 12:41:30 +0800 Subject: [PATCH 33/64] Improvements to label editor --- .../Share/Views/EditLabelsSheet.swift | 22 +- .../App/Views/Labels/ApplyLabelsView.swift | 18 +- .../App/Views/Labels/LabelsViewModel.swift | 9 +- .../Sources/App/Views/SearchBar.swift | 200 ++++++++++++++++++ .../Sources/Views/Colors/Colors.swift | 2 + .../Contents.json | 38 ++++ .../OmnivoreKit/Sources/Views/SearchBar.swift | 59 ------ 7 files changed, 274 insertions(+), 74 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/SearchBar.swift create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json delete mode 100644 apple/OmnivoreKit/Sources/Views/SearchBar.swift diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift index 77fe27ff6..834081a7f 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift @@ -33,11 +33,13 @@ public struct EditLabelsSheet: View { UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2) } + @MainActor func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) { - if labelsViewModel.selectedLabels.contains(label) { - labelsViewModel.selectedLabels.remove(label) + if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) { + labelsViewModel.selectedLabels.remove(at: idx) } else { - labelsViewModel.selectedLabels.insert(label) + labelsViewModel.labelSearchFilter = "" + labelsViewModel.selectedLabels.append(label) } if let linkedItem = viewModel.linkedItem { @@ -47,13 +49,19 @@ public struct EditLabelsSheet: View { var content: some View { VStack(spacing: 15) { - SearchBar(searchTerm: $labelsViewModel.labelSearchFilter) + LabelsEntryView( + searchTerm: $labelsViewModel.labelSearchFilter, + viewModel: labelsViewModel + ) // swiftlint:disable line_length ScrollView { - LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), - selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), - onLabelTap: onLabelTap) + LabelsMasonaryView( + labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), + selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter), + onLabelTap: onLabelTap + ) + Button( action: { labelsViewModel.showCreateLabelModal = true }, label: { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index ac264390e..b16b41ffe 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -1,3 +1,4 @@ + import Models import Services import SwiftUI @@ -49,9 +50,17 @@ struct ApplyLabelsView: View { var innerBody: some View { VStack { - SearchBar(searchTerm: $viewModel.labelSearchFilter) + if !viewModel.labels.isEmpty { + LabelsEntryView( + searchTerm: $viewModel.labelSearchFilter, + viewModel: viewModel + ) .padding(.vertical, 8) .padding(.horizontal, 16) + } + if viewModel.labelSearchFilter.count >= 63 { + Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote) + } List { Section { @@ -59,9 +68,12 @@ struct ApplyLabelsView: View { Button( action: { if isSelected(label) { - viewModel.selectedLabels.remove(label) + if let idx = viewModel.selectedLabels.firstIndex(of: label) { + viewModel.selectedLabels.remove(at: idx) + } } else { - viewModel.selectedLabels.insert(label) + viewModel.labelSearchFilter = "" + viewModel.selectedLabels.append(label) } }, label: { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index fe7c77b05..b39875853 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -2,13 +2,12 @@ import CoreData import Models import Services import SwiftUI -import Views @MainActor public final class LabelsViewModel: ObservableObject { let labelNameMaxLength = 64 @Published var isLoading = false - @Published var selectedLabels = Set() + @Published var selectedLabels = [LinkedItemLabel]() @Published var unselectedLabels = Set() @Published var labels = [LinkedItemLabel]() @Published var showCreateLabelModal = false @@ -36,7 +35,7 @@ import Views await loadLabelsFromStore(dataService: dataService) for label in labels { if selLabels.contains(label) { - selectedLabels.insert(label) + selectedLabels.append(label) } else { unselectedLabels.insert(label) } @@ -50,7 +49,7 @@ import Views } for label in self.labels { if selLabels.contains(label) { - self.selectedLabels.insert(label) + self.selectedLabels.append(label) } else { self.unselectedLabels.insert(label) } @@ -100,7 +99,7 @@ import Views if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel { labels.insert(label, at: 0) - selectedLabels.insert(label) + selectedLabels.append(label) } isLoading = false diff --git a/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift new file mode 100644 index 000000000..6be364265 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift @@ -0,0 +1,200 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor +protocol Entry { + func item(parent: LabelsEntryView) -> AnyView +} + +@MainActor +private struct LabelEntry: Entry { + let label: LinkedItemLabel + + func item(parent _: LabelsEntryView) -> AnyView { + if let name = label.name, let hex = label.color, let color = Color(hex: hex) { + return AnyView(LibraryItemLabelView(text: name, color: color)) + } + return AnyView(EmptyView()) + } +} + +@MainActor +public struct LabelsEntryView: View { + @Binding var searchTerm: String + @State var viewModel: LabelsViewModel + @State var lastSelected = false + @State var justInserted = false + + let entries: [Entry] + + @State private var totalHeight = CGFloat.zero + @FocusState private var textFieldFocused: Bool + @FocusState private var neverFocused: Bool + + public init( + searchTerm: Binding, + viewModel: LabelsViewModel + ) { + self._searchTerm = searchTerm + self.viewModel = viewModel + + self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) }) + } + + func onTextSubmit() { + if searchTerm.count < 1 { + return + } + + // first see if there is a matching label + let term = searchTerm.lowercased() + if let label = viewModel.labels.first(where: { $0.name?.lowercased() == term }) { + justInserted = true + searchTerm = "" + if !viewModel.selectedLabels.contains(label) { + viewModel.selectedLabels.append(label) + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + lastSelected = false + textFieldFocused = true + justInserted = false + } + } + } + + var deletableTextField: some View { + let str = NSAttributedString( + string: searchTerm, + attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)] + ) + // Round it up to avoid jitter when typing + let textWidth = max(25.0, Double(Int(str.size().width + 1))) + let result = TextField("", text: $searchTerm) + .frame(alignment: .topLeading) + .frame(height: 25) + .frame(width: textWidth) + .padding(5) + .font(Font.system(size: 14)) + .multilineTextAlignment(.leading) + .onChange(of: searchTerm, perform: { newValue in + print("NEW VALUE: ", newValue.count) + if searchTerm.count >= 64 { + searchTerm = String(searchTerm.prefix(64)) + } + if searchTerm.isEmpty { + // When we insert a new item we set the text to "" so this block is triggered + // we need to ignore that special case. + if justInserted { + justInserted = false + return + } + if lastSelected { + if viewModel.selectedLabels.count > 0 { + lastSelected = false + viewModel.selectedLabels.removeLast() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + textFieldFocused = true + } + } + } else { + lastSelected = true + searchTerm = "\u{200B}" + } + } else if searchTerm != "\u{200B}" { + lastSelected = false + } + }) + .onSubmit { + onTextSubmit() + } + return result + } + + func onTextDelete() -> Bool { if searchTerm.isEmpty { + if lastSelected { + if viewModel.selectedLabels.count > 0 { + viewModel.selectedLabels.removeLast() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + textFieldFocused = true + } + } + } else { + lastSelected = true + } + return true + } + return false + } + + public var body: some View { + // HStack(spacing: 0) { + VStack { + GeometryReader { geometry in + self.generateLabelsContent(in: geometry) + } + }.padding(0) + .frame(height: totalHeight) + .background(Color.extensionPanelBackground) + .cornerRadius(8) + .onAppear { + textFieldFocused = true + } + .onTapGesture { + textFieldFocused = true + } + .transaction { $0.animation = nil } + } + + private func generateLabelsContent(in geom: GeometryProxy) -> some View { + var width = CGFloat.zero + var height = CGFloat.zero + + return ZStack(alignment: .topLeading) { + ForEach(Array(self.entries.enumerated()), id: \.offset) { _, entry in + entry.item(parent: self) + .padding(5) + .alignmentGuide(.leading, computeValue: { dim in + if abs(width - dim.width) > geom.size.width { + width = 0 + height -= dim.height + } + let result = width + width -= dim.width + return result + }) + .alignmentGuide(.top, computeValue: { _ in + let result = height + return result + }) + } + + deletableTextField + .alignmentGuide(.leading, computeValue: { dim in + if abs(width - dim.width) > geom.size.width { + width = 0 + height -= dim.height + } + let result = width + width = 0 + return result + }) + .alignmentGuide(.top, computeValue: { _ in + let result = height + height = 0 + return result + }).focused($textFieldFocused) + }.background(viewHeightReader($totalHeight)) + } + + private func viewHeightReader(_ binding: Binding) -> some View { + GeometryReader { geometry -> Color in + let rect = geometry.frame(in: .local) + DispatchQueue.main.async { + binding.wrappedValue = rect.size.height + } + return .clear + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 76f11d506..0e47bffd6 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -50,6 +50,8 @@ public extension Color { static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) } static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) } + static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) } + // Apple system UIColor equivalents #if os(iOS) static var systemBackground: Color { Color(.systemBackground) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json new file mode 100644 index 000000000..127e33e80 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/Views/SearchBar.swift deleted file mode 100644 index ae9dc8957..000000000 --- a/apple/OmnivoreKit/Sources/Views/SearchBar.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -public struct SearchBar: View { - @Binding var searchTerm: String - @FocusState private var isFocused: Bool - - public init( - searchTerm: Binding - ) { - self._searchTerm = searchTerm - } - - public var body: some View { - HStack(spacing: 0) { - TextField("Add Labels", text: $searchTerm) - .frame(height: 36) - .frame(maxWidth: .infinity) - .padding(.leading, 28) - .padding(.trailing, 28) - .focused($isFocused) - .overlay( - HStack { - Image(systemName: "magnifyingglass") - .resizable() - .frame(width: 14, height: 14) - .foregroundColor(.appGrayText) - .padding(.leading, 8) - - Spacer() - } - ) - - if isFocused { - Button( - action: { - self.isFocused = false - }, - label: { - Image(systemName: "multiply.circle.fill") - .foregroundColor(.gray) - } - ) - .padding(.trailing, 8) - .transition(.move(edge: .trailing)) - } - } - .background(Color.appButtonBackground) - .cornerRadius(8) - .frame(height: 36) - .onChange(of: isFocused) { isFocused in - if !isFocused { - searchTerm = "" - } - } - .onTapGesture { - isFocused = true - } - } -} From 1b85fb0c15fade4fff0f755157f0f0972cf60cac Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 14:29:15 +0800 Subject: [PATCH 34/64] Add feeds, better delete handling --- .../Share/Views/EditLabelsSheet.swift | 2 +- .../App/Views/Home/HomeFeedDisplayText.swift | 2 + .../App/Views/Labels/ApplyLabelsView.swift | 8 +- .../App/Views/Labels/LabelsViewModel.swift | 10 ++- ...{SearchBar.swift => LabelsEntryView.swift} | 76 ++++++++----------- .../Sources/Models/LinkedItemFilter.swift | 8 ++ 6 files changed, 54 insertions(+), 52 deletions(-) rename apple/OmnivoreKit/Sources/App/Views/{SearchBar.swift => LabelsEntryView.swift} (72%) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift index 834081a7f..c9ca6c639 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift @@ -38,7 +38,7 @@ public struct EditLabelsSheet: View { if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) { labelsViewModel.selectedLabels.remove(at: idx) } else { - labelsViewModel.labelSearchFilter = "" + labelsViewModel.labelSearchFilter = ZWSP labelsViewModel.selectedLabels.append(label) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift index eb57e0d4f..745d37a77 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift @@ -11,6 +11,8 @@ extension LinkedItemFilter { return LocalText.readLaterGeneric case .newsletters: return LocalText.newslettersGeneric + case .feeds: + return "Feeds" case .recommended: return "Recommended" case .all: diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index b16b41ffe..9b6129bd6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -72,7 +72,7 @@ struct ApplyLabelsView: View { viewModel.selectedLabels.remove(at: idx) } } else { - viewModel.labelSearchFilter = "" + viewModel.labelSearchFilter = ZWSP viewModel.selectedLabels.append(label) } }, @@ -204,9 +204,11 @@ struct ApplyLabelsView: View { extension Sequence where Element == LinkedItemLabel { func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] { - if searchFilter.isEmpty { + if searchFilter.isEmpty || searchFilter == ZWSP { return map { $0 } // return the identity of the sequence } - return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) } + let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1) + let trimmed = searchFilter.suffix(from: index).lowercased() + return filter { ($0.name ?? "").lowercased().contains(trimmed) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index b39875853..11b652f16 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -11,7 +11,7 @@ import SwiftUI @Published var unselectedLabels = Set() @Published var labels = [LinkedItemLabel]() @Published var showCreateLabelModal = false - @Published var labelSearchFilter = "" + @Published var labelSearchFilter = ZWSP public init() {} @@ -35,7 +35,9 @@ import SwiftUI await loadLabelsFromStore(dataService: dataService) for label in labels { if selLabels.contains(label) { - selectedLabels.append(label) + if !selectedLabels.contains(label) { + selectedLabels.append(label) + } } else { unselectedLabels.insert(label) } @@ -49,7 +51,9 @@ import SwiftUI } for label in self.labels { if selLabels.contains(label) { - self.selectedLabels.append(label) + if !self.selectedLabels.contains(label) { + self.selectedLabels.append(label) + } } else { self.unselectedLabels.insert(label) } diff --git a/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift similarity index 72% rename from apple/OmnivoreKit/Sources/App/Views/SearchBar.swift rename to apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift index 6be364265..9cb8bab3f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift @@ -3,6 +3,8 @@ import Services import SwiftUI import Views +let ZWSP = "\u{200B}" + @MainActor protocol Entry { func item(parent: LabelsEntryView) -> AnyView @@ -24,14 +26,11 @@ private struct LabelEntry: Entry { public struct LabelsEntryView: View { @Binding var searchTerm: String @State var viewModel: LabelsViewModel - @State var lastSelected = false - @State var justInserted = false let entries: [Entry] @State private var totalHeight = CGFloat.zero @FocusState private var textFieldFocused: Bool - @FocusState private var neverFocused: Bool public init( searchTerm: Binding, @@ -44,22 +43,21 @@ public struct LabelsEntryView: View { } func onTextSubmit() { - if searchTerm.count < 1 { + let index = searchTerm.index(searchTerm.startIndex, offsetBy: 1) + let trimmed = searchTerm.suffix(from: index).lowercased() + + if trimmed.count < 1 { return } - // first see if there is a matching label - let term = searchTerm.lowercased() - if let label = viewModel.labels.first(where: { $0.name?.lowercased() == term }) { - justInserted = true - searchTerm = "" + if let label = viewModel.labels.first(where: { $0.name?.lowercased() == trimmed }) { if !viewModel.selectedLabels.contains(label) { viewModel.selectedLabels.append(label) } + + searchTerm = ZWSP DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - lastSelected = false textFieldFocused = true - justInserted = false } } } @@ -78,32 +76,20 @@ public struct LabelsEntryView: View { .padding(5) .font(Font.system(size: 14)) .multilineTextAlignment(.leading) - .onChange(of: searchTerm, perform: { newValue in - print("NEW VALUE: ", newValue.count) + .onChange(of: searchTerm, perform: { _ in if searchTerm.count >= 64 { searchTerm = String(searchTerm.prefix(64)) } if searchTerm.isEmpty { - // When we insert a new item we set the text to "" so this block is triggered - // we need to ignore that special case. - if justInserted { - justInserted = false - return - } - if lastSelected { - if viewModel.selectedLabels.count > 0 { - lastSelected = false - viewModel.selectedLabels.removeLast() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - textFieldFocused = true - } + if viewModel.selectedLabels.count > 0 { + viewModel.selectedLabels.removeLast() + searchTerm = ZWSP + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + textFieldFocused = true } } else { - lastSelected = true - searchTerm = "\u{200B}" + searchTerm = ZWSP } - } else if searchTerm != "\u{200B}" { - lastSelected = false } }) .onSubmit { @@ -112,21 +98,21 @@ public struct LabelsEntryView: View { return result } - func onTextDelete() -> Bool { if searchTerm.isEmpty { - if lastSelected { - if viewModel.selectedLabels.count > 0 { - viewModel.selectedLabels.removeLast() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { - textFieldFocused = true - } - } - } else { - lastSelected = true - } - return true - } - return false - } +// func onTextDelete() -> Bool { if searchTerm.isEmpty { +// if lastSelected { +// if viewModel.selectedLabels.count > 0 { +// viewModel.selectedLabels.removeLast() +// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { +// textFieldFocused = true +// } +// } +// } else { +// lastSelected = true +// } +// return true +// } +// return false +// } public var body: some View { // HStack(spacing: 0) { diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift index b9e819a32..be54b26ef 100644 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift @@ -2,6 +2,7 @@ import Foundation public enum LinkedItemFilter: String, CaseIterable { case inbox + case feeds case readlater case newsletters case recommended @@ -17,6 +18,8 @@ public extension LinkedItemFilter { switch self { case .inbox: return "in:inbox" + case .feeds: + return "label:RSS" case .readlater: return "in:library" case .newsletters: @@ -76,6 +79,11 @@ public extension LinkedItemFilter { format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" ) return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate]) + case .feeds: + let feedLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate]) case .recommended: // non-archived or deleted items with the Newsletter label let recommendedPredicate = NSPredicate( From 557ca6adaa925175e9b676e8637482bd3a5abd77 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 14:39:43 +0800 Subject: [PATCH 35/64] Create labels on submit --- .../Sources/App/Views/LabelsEntryView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift index 9cb8bab3f..7a5141799 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift @@ -26,6 +26,7 @@ private struct LabelEntry: Entry { public struct LabelsEntryView: View { @Binding var searchTerm: String @State var viewModel: LabelsViewModel + @EnvironmentObject var dataService: DataService let entries: [Entry] @@ -55,6 +56,17 @@ public struct LabelsEntryView: View { viewModel.selectedLabels.append(label) } + searchTerm = ZWSP + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + textFieldFocused = true + } + } else { + viewModel.createLabel( + dataService: dataService, + name: trimmed, + color: Gradient.randomColor(str: trimmed, offset: 1), + description: nil + ) searchTerm = ZWSP DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { textFieldFocused = true From 3575008e28291a88344a1f91b7754ecf8ca93e6f Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 15:15:55 +0800 Subject: [PATCH 36/64] Set iconURL from response if possible --- .../AppExtensions/Share/ShareExtensionViewModel.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 37792c953..e74d34a55 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -192,6 +192,16 @@ public class ShareExtensionViewModel: ObservableObject { if let title = self.linkedItem?.title { self.title = title } + if let iconURL = self.linkedItem?.imageURL { + self.iconURL = iconURL + } + if let noteHighlight = self.linkedItem?.highlights? + .compactMap({ $0 as? Highlight }) + .first(where: { $0.type == "NOTE" }), + let noteText = noteHighlight.annotation + { + self.noteText = noteText + } if let urlStr = self.linkedItem?.pageURLString, let hostname = URL(string: urlStr)?.host { self.url = hostname } else { From a28f618b08df82543f6162f436b0e6b4e22fff35 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 15:16:05 +0800 Subject: [PATCH 37/64] Consistent casing for titles --- packages/web/components/patterns/CardMenu.tsx | 12 ++++++------ packages/web/components/patterns/HighlightBar.tsx | 6 +++--- .../patterns/LibraryCards/LibraryHoverActions.tsx | 2 +- .../web/components/patterns/ReaderDropdownMenu.tsx | 4 ++-- packages/web/components/templates/UploadModal.tsx | 2 +- .../templates/article/AddBulkLabelsModal.tsx | 2 +- .../templates/article/ArticleActionsMenu.tsx | 4 ++-- .../components/templates/article/NotebookModal.tsx | 4 ++-- .../templates/article/VerticalArticleActions.tsx | 6 +++--- .../templates/homeFeed/LibraryFilterMenu.tsx | 2 +- .../web/components/templates/reader/ReaderHeader.tsx | 2 +- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/web/components/patterns/CardMenu.tsx b/packages/web/components/patterns/CardMenu.tsx index 27b1207b4..3f190f15c 100644 --- a/packages/web/components/patterns/CardMenu.tsx +++ b/packages/web/components/patterns/CardMenu.tsx @@ -44,35 +44,35 @@ export function CardMenu(props: CardMenuProps): JSX.Element { onSelect={() => { props.actionHandler('set-labels') }} - title="Set Labels" + title="Set labels" /> { props.actionHandler('open-notebook') }} - title="Open Notebook" + title="Open notebook" /> props.actionHandler('showOriginal')} - title="Open Original" + title="Open original" /> props.actionHandler('editTitle')} - title="Edit Metadata" + title="Edit metadata" /> {props.item.readingProgressPercent < 98 ? ( { props.actionHandler('mark-read') }} - title="Mark Read" + title="Mark read" /> ) : ( { props.actionHandler('mark-unread') }} - title="Mark Unread" + title="Mark unread" /> )} { if (!props.isNewHighlight && props.highlightColor != color) { props.handleButtonClick('updateColor', color) @@ -151,7 +151,7 @@ function BarContent(props: HighlightBarProps): JSX.Element { {!props.isNewHighlight && ( <> )}