├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MIGRATING.md ├── PRIVACY.md ├── README.md ├── assets ├── chrome-48.png ├── firefox-48.png ├── hero.png ├── logo.png └── microsoft-edge-48.png ├── babel.config.json ├── package.json ├── packages ├── browser-extension-oauth2 │ ├── .babelrc │ ├── .eslintrc │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ └── src │ │ ├── HttpError.js │ │ ├── Oauth2.js │ │ ├── Storage.js │ │ ├── index.js │ │ └── utils │ │ ├── enhancedFetch.js │ │ ├── generateRandomString.js │ │ ├── getParamsFromCallbackUrl.js │ │ ├── getRedirectUrl.js │ │ └── index.js ├── common │ ├── .eslintrc │ ├── components │ │ ├── Alerts │ │ │ └── index.js │ │ ├── IconButton │ │ │ └── index.js │ │ ├── MarkdownEditor │ │ │ └── index.js │ │ ├── MarkdownViewer │ │ │ ├── index.js │ │ │ └── styled.js │ │ ├── TagDialog │ │ │ └── index.js │ │ └── index.js │ ├── constants.js │ ├── fonts │ │ └── msyh.ttf │ ├── icons │ │ ├── evernote.svg │ │ ├── googledocs.svg │ │ ├── index.js │ │ ├── markdown.svg │ │ ├── onenote.svg │ │ ├── patreon.svg │ │ └── paypal.svg │ ├── index.js │ ├── package.json │ ├── services │ │ ├── file.js │ │ ├── index.js │ │ ├── integration │ │ │ ├── evernote │ │ │ │ ├── generator.js │ │ │ │ └── index.js │ │ │ ├── googledocs │ │ │ │ ├── generator.js │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── onenote │ │ │ │ ├── generator.js │ │ │ │ └── index.js │ │ │ ├── service.js │ │ │ └── utils.js │ │ ├── markdown.js │ │ ├── pdf │ │ │ └── index.js │ │ └── storage │ │ │ ├── BrowserStorage.js │ │ │ ├── LocalStorage.js │ │ │ ├── Storage.js │ │ │ ├── StorageFactory.js │ │ │ └── index.js │ ├── store │ │ ├── alerts.js │ │ ├── index.js │ │ ├── page.js │ │ ├── settings.js │ │ └── tagDialog.js │ ├── utils │ │ ├── addNoteToList.js │ │ ├── addTagToList.js │ │ ├── buildAutoSeekUrl.js │ │ ├── capitalize.js │ │ ├── delay.js │ │ ├── enhancedFetch.js │ │ ├── generatePageId.js │ │ ├── getFileUrl.js │ │ ├── getVersion.js │ │ ├── index.js │ │ ├── retry.js │ │ ├── secondsToTime.js │ │ ├── sendMessage.js │ │ └── uuid-namespace.js │ └── withTheme.js ├── extension │ ├── .eslintrc │ ├── _locales │ │ ├── en │ │ │ └── messages.json │ │ └── zh-CN │ │ │ └── messages.json │ ├── icons │ │ ├── icon-128.png │ │ ├── icon-16.png │ │ └── icon-48.png │ ├── installed.png │ ├── manifest.common.json │ ├── package.json │ ├── src │ │ ├── background │ │ │ ├── index.js │ │ │ └── migrations │ │ │ │ └── 0.6.4.js │ │ ├── i18n.js │ │ ├── index.js │ │ ├── options │ │ │ ├── containers │ │ │ │ ├── App │ │ │ │ │ ├── Drawer │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Header │ │ │ │ │ │ ├── Toolbar.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Snackbar │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── constants.js │ │ │ │ │ └── index.js │ │ │ │ ├── Bookmarks │ │ │ │ │ ├── BookmarkItem │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── NoBookmark │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Toolbar │ │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ │ ├── Page │ │ │ │ │ ├── Toolbar │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── VideoNoteItem │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styled.js │ │ │ │ │ └── index.js │ │ │ │ └── Settings │ │ │ │ │ ├── ExportAndImport │ │ │ │ │ └── index.js │ │ │ │ │ ├── Video │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ ├── globalStyle.js │ │ │ ├── index.html │ │ │ ├── index.js │ │ │ ├── services │ │ │ │ └── importData.js │ │ │ └── store │ │ │ │ ├── app.js │ │ │ │ ├── bookmarks.js │ │ │ │ ├── index.js │ │ │ │ └── store.js │ │ ├── ui │ │ │ ├── components │ │ │ │ ├── ScrollableList │ │ │ │ │ └── index.js │ │ │ │ ├── Spinner │ │ │ │ │ └── index.js │ │ │ │ └── TextButton │ │ │ │ │ └── index.js │ │ │ ├── containers │ │ │ │ ├── App │ │ │ │ │ ├── Footer │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Header │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styled.js │ │ │ │ │ ├── Search │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styled.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── styled.js │ │ │ │ ├── ReloadView │ │ │ │ │ └── index.js │ │ │ │ ├── SearchView │ │ │ │ │ ├── BookmarkItem │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── NoteItem │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styled.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── styled.js │ │ │ │ └── VideoNotesView │ │ │ │ │ ├── Editor │ │ │ │ │ └── index.js │ │ │ │ │ ├── NoteItem │ │ │ │ │ ├── index.js │ │ │ │ │ └── styled.js │ │ │ │ │ ├── SupportExtension │ │ │ │ │ ├── Share │ │ │ │ │ │ ├── icons │ │ │ │ │ │ │ ├── copylink-48.png │ │ │ │ │ │ │ ├── facebook-48.png │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── twitter-48.png │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Sponsor │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Star │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── styled.js │ │ │ │ │ ├── Tags │ │ │ │ │ └── index.js │ │ │ │ │ ├── Toolbar │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ ├── content-script.js │ │ │ ├── globalStyle.js │ │ │ ├── hooks │ │ │ │ ├── index.js │ │ │ │ ├── useLoadScreenshots.js │ │ │ │ └── useSyncNotes.js │ │ │ ├── index.js │ │ │ ├── services │ │ │ │ ├── dom │ │ │ │ │ ├── ClassWatcher.js │ │ │ │ │ ├── getRangeFromElement.js │ │ │ │ │ ├── getRangeFromSelection.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── isDescendant.js │ │ │ │ │ ├── isElement.js │ │ │ │ │ └── isHidden.js │ │ │ │ ├── player │ │ │ │ │ ├── EmbedlyPlayer.js │ │ │ │ │ ├── HTML5Player.js │ │ │ │ │ ├── HookPlayer.js │ │ │ │ │ ├── Player.js │ │ │ │ │ ├── PlayerFactory.js │ │ │ │ │ ├── YoutubeIframePlayer.js │ │ │ │ │ ├── YoutubePlayer.js │ │ │ │ │ └── index.js │ │ │ │ └── sync │ │ │ │ │ ├── Coursera.js │ │ │ │ │ ├── SyncBase.js │ │ │ │ │ ├── SyncFactory.js │ │ │ │ │ ├── Udemy.js │ │ │ │ │ └── index.js │ │ │ ├── store │ │ │ │ ├── app.js │ │ │ │ ├── index.js │ │ │ │ ├── search.js │ │ │ │ ├── store.js │ │ │ │ └── videoNotes.js │ │ │ └── utils │ │ │ │ ├── index.js │ │ │ │ └── takeScreenshot.js │ │ └── vendors │ │ │ ├── browser-polyfill.js │ │ │ ├── embedly.js │ │ │ └── youtube-iframe-api.js │ ├── webpack.common.js │ ├── webpack.config.js │ └── webpack.dev.config.js ├── landing │ ├── .eslintrc │ ├── .gitignore │ ├── README.md │ ├── gatsby-config.js │ ├── gatsby-node.js │ ├── microsoft-identity-association.json │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── Logo.js │ │ │ ├── header.js │ │ │ ├── image.js │ │ │ ├── layout.js │ │ │ └── seo.js │ │ ├── i18n.js │ │ ├── locales │ │ │ └── en │ │ │ │ └── header.json │ │ └── pages │ │ │ ├── 404.js │ │ │ ├── index.js │ │ │ └── privacy.js │ └── static │ │ ├── fonts │ │ ├── Inter-Bold.woff │ │ ├── Inter-Bold.woff2 │ │ ├── Inter-Regular.woff │ │ ├── Inter-Regular.woff2 │ │ └── fonts.css │ │ ├── images │ │ ├── chrome-48.png │ │ ├── favicon.png │ │ ├── firefox-48.png │ │ ├── hero.png │ │ ├── logo.png │ │ └── microsoft-edge-48.png │ │ └── styles │ │ ├── _all.scss │ │ ├── _grid.scss │ │ ├── _reset.scss │ │ └── main.scss ├── oauth-api │ ├── .eslintrc │ ├── README.md │ ├── apis │ │ └── evernote-handler.js │ ├── package.json │ └── serverless.yml └── playground │ ├── .eslintrc │ ├── favicon.ico │ ├── index.html │ ├── mov_bbb.mp4 │ ├── package.json │ ├── src │ ├── components │ │ ├── Embedly.js │ │ ├── HTML5Video.js │ │ └── YoutubeIframe.js │ └── index.js │ └── webpack.config.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | rollup.*.js 2 | webpack.*.js 3 | 4 | dist 5 | node_modules 6 | vendors 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es2020": true 5 | }, 6 | "parserOptions": { 7 | "sourceType": "module" 8 | }, 9 | "parser": "babel-eslint", 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:prettier/recommended" 13 | ], 14 | "globals": { 15 | "logger": "readonly", 16 | "process": "readonly" 17 | } 18 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [shuowu] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: yinote 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Browser (please complete the following information):** 27 | - Browser [e.g. chrome, safari] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.zip 4 | .env 5 | .serverless/ 6 | 7 | lerna-debug.log 8 | 9 | artifactory/ 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | rollup.*.js 2 | webpack.*.js 3 | 4 | dist 5 | node_modules 6 | vendors 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.6.0 4 | 5 | - [782e805](https://github.com/shuowu/yi-note/commit/782e80572c0ee11b644859c4ae59ea06a8924654) 6 | - Supports export markdown file in options page 7 | - Keeps markdown structure in generated PDF and other services integration. 8 | 9 | ## 1.5.4 10 | 11 | ### Bug fixes 12 | 13 | - Fixes MS OneNote export issue 14 | 15 | ## 1.5.3 16 | 17 | ### Bug fixes 18 | 19 | - [#53](https://github.com/shuowu/yi-note/issues/53) Fix timestamp catching issue 20 | 21 | ## 1.5.2 22 | 23 | ### Bug fixes 24 | 25 | - [#50](https://github.com/shuowu/yi-note/issues/50) 26 | - Fix failed to send notes to googledocs when missing title or description 27 | - Fix html5 video cross origin screenshot issue 28 | 29 | ## 1.5.1 30 | 31 | ### Bug fixes 32 | 33 | - [#47](https://github.com/shuowu/yi-note/issues/47) Fix word break issue in editor 34 | - [#48](https://github.com/shuowu/yi-note/issues/48) Fix generating PDF issue when no title available 35 | - [#49](https://github.com/shuowu/yi-note/issues/49) Fix data import issue for version above 1.0.0 36 | 37 | ## 1.5.0 38 | 39 | - [78328ff](https://github.com/shuowu/yi-note/commit/78328ffd668ae5edbde14b543f01c021a4d06096) Add setting to pause video when editing note 40 | 41 | ## 1.4.2 42 | 43 | - [e74dc40](https://github.com/shuowu/yi-note/commit/e74dc40293b356b80031b77afa0cebc6061d84c7) Fix player reset issue when url change 44 | 45 | ## 1.4.1 46 | 47 | - [#42](https://github.com/shuowu/yi-note/issues/42) Fix UI states reset when url change in host page 48 | 49 | ## 1.4.0 50 | 51 | ### Features 52 | 53 | - Support markdown in note editor 54 | - Support screenshot annotation in options page 55 | - Add video settings in options page 56 | - Support tags to manage bookmarks 57 | 58 | ## 1.3.0 59 | 60 | ### Features 61 | 62 | - [#16](https://github.com/shuowu/yi-note/issues/16) Sync notes from Coursera notes 63 | - [#17](https://github.com/shuowu/yi-note/issues/17) Sync notes from Udemy notes 64 | - [#8](https://github.com/shuowu/yi-note/issues/8) Support embedly player 65 | - [#21](https://github.com/shuowu/yi-note/issues/21) Add hook player to expose interface for website to integrate with 66 | - [#30](https://github.com/shuowu/yi-note/issues/30) Add option for user to customize playback seconds 67 | 68 | ### Bug fixes 69 | 70 | - [#29](https://github.com/shuowu/yi-note/issues/29) Fix PDF link auto seek issue 71 | 72 | ## 1.2.0 73 | 74 | ### Features 75 | 76 | - [#6](https://github.com/shuowu/yi-note/issues/6) Send notes to Evernote 77 | - [#19](https://github.com/shuowu/yi-note/issues/19) Enable share YiNote to social networks 78 | 79 | ### Bug fixes 80 | 81 | - [#13](https://github.com/shuowu/yi-note/issues/13) Options page bookmark item image position issue 82 | 83 | ## 1.1.0 84 | 85 | ### Features 86 | 87 | - [#15](https://github.com/shuowu/yi-note/issues/15) Integrate with MS OneNote 88 | - [#14](https://github.com/shuowu/yi-note/issues/14) Integrate with Google Docs 89 | - [#2](https://github.com/shuowu/yi-note/issues/2) Support auto-seek on video if queryParam provided in url 90 | - [#3](https://github.com/shuowu/yi-note/pull/3) Support Chinese character when generate PDF 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to YiNote Repo 2 | 3 | ## Commit Message Guidelines 4 | 5 | ### Git Commit Messages 6 | 7 | We use an adapted form of [Conventional Commits](http://conventionalcommits.org/). 8 | 9 | - Use the present tense ("Adds feature" not "Added feature") 10 | - Limit the first line to 72 characters or less 11 | - Add one feature per commit. If you have multiple features, have multiple commits. 12 | 13 | ### Template 14 | 15 | : Short Description of Commit 16 | 17 | More detailed description of commit 18 | 19 | (Optional) Resolves: 20 | 21 | ### Template for specific package change 22 | 23 | []: Short Description of Commit 24 | 25 | More detailed description of commit 26 | 27 | (Optional) Resolves: 28 | 29 | ### Type 30 | 31 | Types include: 32 | 33 | - `feat` when creating a new feature 34 | - `fix` when fixing a bug 35 | - `test` when adding tests 36 | - `refactor` when improving the format/structure of the code 37 | - `docs` when writing docs 38 | - `release` when pushing a new release 39 | - `chore` others (ex: upgrading/downgrading dependencies) 40 | 41 | ### Example 42 | 43 | docs: Updates CONTRIBUTING.md 44 | 45 | Updates Contributing.md with new template 46 | 47 | Resolves: #1234 48 | 49 | ### Example for specific package change 50 | 51 | fix(background): Fixes background bug 52 | 53 | Fixes a very bad bug in background 54 | 55 | Resolves: #5678 56 | -------------------------------------------------------------------------------- /MIGRATING.md: -------------------------------------------------------------------------------- 1 | # YiNote browser extension migration guide 2 | 3 | ## From 0.6.4 to 1.0.0 4 | 5 | For users who have already installed this extension in chrome browser, it will be updated automatically by the browser from v0.6.4 to v1.0.0. All bookmarks and notes will be migrated from the update. 6 | 7 | **Note:** v1.0.0 removed unstable features like web article annotation and note sharing. This features will be redesigned and implemented in the near future, which also means webpage highlights data won't still be available in version 1.0.0. 8 | 9 | Two backup files will be saved while extension updating. Please file issues in this repo if older version support is still needed. 10 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Last updated May 13, 2020 4 | 5 | Thanks for choosing YiNote extension. YiNote is committed to protecting your personal information and your right to privacy. 6 | 7 | In short, YiNote browser extension don't share your information with anyone. All data generate from the extension will be saved in browser storage and protect by browser securely. No trackiing technology is being applied in the extension. 8 | 9 | If you have any questions or concerns about the notice, or how this extension behaves with regards to your personal information, please contact me at wushuo2010@gmail.com. 10 | -------------------------------------------------------------------------------- /assets/chrome-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/assets/chrome-48.png -------------------------------------------------------------------------------- /assets/firefox-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/assets/firefox-48.png -------------------------------------------------------------------------------- /assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/assets/hero.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/assets/logo.png -------------------------------------------------------------------------------- /assets/microsoft-edge-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/assets/microsoft-edge-48.png -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | "js-logger", 5 | "@babel/plugin-proposal-class-properties", 6 | "@babel/plugin-proposal-private-methods", 7 | "@babel/plugin-transform-runtime", 8 | [ 9 | 10 | "@quickbaseoss/babel-plugin-styled-components-css-namespace", 11 | { "cssNamespace": ".yi-note" } 12 | ], 13 | "styled-components" 14 | ] 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yi-note", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "YiNote monorepo", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "start:landing": "yarn workspace @yi-note/landing start", 11 | "build:landing": "yarn workspace @yi-note/landing build", 12 | "start:playground": "yarn workspace @yi-note/playground start", 13 | "start:ext": "yarn workspace @yi-note/extension start:dev", 14 | "build:ext": "yarn workspace @yi-note/extension build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/shuowu/yi-note.git" 19 | }, 20 | "author": "Shuo Wu ", 21 | "license": "GNU GPLv3", 22 | "bugs": { 23 | "url": "https://github.com/shuowu/yi-note/issues" 24 | }, 25 | "homepage": "https://www.yinote.co", 26 | "devDependencies": { 27 | "babel-eslint": "^10.1.0", 28 | "eslint": "^6.7.1", 29 | "eslint-config-prettier": "^6.11.0", 30 | "eslint-plugin-prettier": "^3.1.1", 31 | "prettier": "1.19.1" 32 | }, 33 | "prettier": { 34 | "semi": true, 35 | "singleQuote": true, 36 | "trailingComma": "none", 37 | "arrowParens": "avoid" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", {"modules": false}] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "globals": { 6 | "browser": "readonly" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Shuo Wu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/README.md: -------------------------------------------------------------------------------- 1 | # browser-extension-oauth2 2 | 3 | This module provides wrapper around browser.identity API for Oauth2.0 implicit flow. This module not only handles retrieving and renewing access token from authorization endpoints and properly cache it in `browser.storage.local`, but also provide enhanced fetch functon, `callApi`, to access protected resources with access token properly binded in headers. 4 | 5 | ## Installation 6 | 7 | Please follow either of following options to use this module in your extension project. 8 | 9 | ### Build extension with bundler like webpack 10 | 11 | ```bash 12 | npm install browser-extension-oauth2 13 | ``` 14 | 15 | ### Add bundle file directly in `manifest.json` 16 | 17 | - Clone repo 18 | 19 | - Install dependencies 20 | 21 | ```bash 22 | npm install 23 | ``` 24 | 25 | - Build bundle 26 | 27 | ```bash 28 | npm run build 29 | ``` 30 | 31 | - Add `index.js` from dist folder to your extension project. Then 32 | 33 | ## Development 34 | 35 | ```bash 36 | npm install 37 | npm run dev 38 | ``` 39 | 40 | ## Examples 41 | 42 | ```js 43 | import Oauth2 from 'browser-extension-oauth2' 44 | 45 | // Initial oauth2 instance 46 | const oauth2 = new Oauth2({ 47 | provider: '{provide name}', // Provider name, this will be used in redirectUrl and as storage key 48 | authorization_endpoint: '{oauth authorization_endpoint}', 49 | client_id: '{registered client id in idp}', 50 | scopes: ['{scope}'], // Scopes for api access 51 | api_base_url: '{api base url}' // Optional, only relative paths need to be provided if callApi method if this config exist 52 | }); 53 | 54 | // Call resource api, this method follows `fetch` API input with addtional renew token logic provided. 55 | // If no access token available, prompt will popup to ask for user's consent. 56 | oauth2.callApi('/protected-resource') 57 | .then(data => { 58 | // Handle resource 59 | }); 60 | ``` 61 | 62 | ### License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/browser-extension-oauth2", 3 | "version": "1.1.0", 4 | "description": "Browser extension oauth2 library", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "rollup -c", 8 | "dev": "rollup -c -w" 9 | }, 10 | "author": "Shuo Wu", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/shuowu/browser-extension-oauth2" 14 | }, 15 | "homepage": "https://github.com/shuowu/browser-extension-oauth2", 16 | "license": "MIT", 17 | "peerDependencies": { 18 | "webextension-polyfill": "^0.6.0" 19 | }, 20 | "dependencies": {}, 21 | "devDependencies": { 22 | "@babel/core": "^7.9.6", 23 | "@babel/preset-env": "^7.9.6", 24 | "@rollup/plugin-node-resolve": "^7.1.3", 25 | "rollup": "^2.10.2", 26 | "rollup-plugin-babel": "^4.4.0", 27 | "rollup-plugin-terser": "^5.3.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import babel from 'rollup-plugin-babel'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | 5 | module.exports = { 6 | input: 'src/index.js', 7 | output: [ 8 | { 9 | file: 'dist/index.es.js', 10 | format: 'es', 11 | sourcemap: true 12 | }, 13 | { 14 | file: 'dist/index.js', 15 | format: 'iife', 16 | name: 'ExtOauth2', 17 | sourcemap: true 18 | } 19 | ], 20 | plugins: [ 21 | resolve(), 22 | babel({ exclude: 'node_modules/**' }), 23 | terser({ 24 | include: [/^.+\.min\.js$/] 25 | }) 26 | ] 27 | }; 28 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/HttpError.js: -------------------------------------------------------------------------------- 1 | class HttpError extends Error { 2 | constructor(status, error) { 3 | super(); 4 | 5 | if (Error.captureStackTrace) { 6 | Error.captureStackTrace(this, HttpError); 7 | } 8 | 9 | this.status = status; 10 | this.error = error; 11 | } 12 | } 13 | 14 | export default HttpError; 15 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/Oauth2.js: -------------------------------------------------------------------------------- 1 | import Storage from './Storage'; 2 | import { 3 | enhancedFetch, 4 | getRedirectUrl, 5 | getParamsFromCallbackUrl, 6 | generateRandomString 7 | } from './utils'; 8 | 9 | const ACESS_TOKEN_KEY = 'accessToken'; 10 | 11 | class Oauth2 { 12 | constructor(config) { 13 | this.provider = config.provider; 14 | this.storage = new Storage(this.provider); 15 | this.authorizationEndpoint = config.authorization_endpoint; 16 | this.responseType = 'token'; 17 | this.clientId = config.client_id; 18 | this.scopes = config.scopes; 19 | this.apiBaseUrl = config.api_base_url; 20 | } 21 | 22 | promptErrorHandler(err) { 23 | let error; 24 | if (typeof err === 'string') { 25 | const params = getParamsFromCallbackUrl(error); 26 | error = new Error(params.error); 27 | } else { 28 | error = err; 29 | } 30 | throw error; 31 | } 32 | 33 | getAccessTokenImplict() { 34 | const state = generateRandomString(); 35 | let url = this.authorizationEndpoint; 36 | url += `?response_type=${this.responseType}`; 37 | url += `&scope=${encodeURIComponent(this.scopes.join(' '))}`; 38 | url += `&client_id=${this.clientId}`; 39 | url += `&redirect_uri=${encodeURIComponent(getRedirectUrl(this.provider))}`; 40 | url += `&state=${state}`; 41 | 42 | return browser.identity 43 | .launchWebAuthFlow({ interactive: true, url }) 44 | .then(url => { 45 | const params = getParamsFromCallbackUrl(url); 46 | const accessToken = params.access_token; 47 | if (params.state !== state) { 48 | throw new Error('Invalid state.'); 49 | } 50 | if (params.error) { 51 | throw new Error(params.error); 52 | } 53 | return this.storage 54 | .set(ACESS_TOKEN_KEY, accessToken) 55 | .then(() => accessToken); 56 | }) 57 | .catch(this.promptErrorHandler); 58 | } 59 | 60 | getAccessToken() { 61 | return this.storage.get(ACESS_TOKEN_KEY).then(accessToken => { 62 | if (accessToken) { 63 | return accessToken; 64 | } 65 | return this.getAccessTokenImplict(); 66 | }); 67 | } 68 | 69 | callApi(path, request = {}) { 70 | const url = this.apiBaseUrl ? `${this.apiBaseUrl}${path}` : path; 71 | return this.getAccessToken() 72 | .then(token => { 73 | request.headers = request.headers || {}; 74 | request.headers = { 75 | ...request.headers, 76 | Authorization: `Bearer ${token}` 77 | }; 78 | return enhancedFetch(url, request); 79 | }) 80 | .catch(error => { 81 | if (error.status === 401) { 82 | return this.storage 83 | .remove(ACESS_TOKEN_KEY) 84 | .then(() => this.callApi(path, request)); 85 | } 86 | throw error.error; 87 | }); 88 | } 89 | } 90 | 91 | export default Oauth2; 92 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/Storage.js: -------------------------------------------------------------------------------- 1 | class Storage { 2 | constructor(namespace) { 3 | this.storage = browser.storage.local; 4 | this.namespace = namespace; 5 | } 6 | 7 | get(key) { 8 | return this.storage.get(this.namespace).then(data => { 9 | data = data[this.namespace] || {}; 10 | return data[key]; 11 | }); 12 | } 13 | 14 | set(key, value) { 15 | return this.storage.get(this.namespace).then(data => { 16 | data = data[this.namespace] || {}; 17 | data[key] = value; 18 | return this.storage.set({ [this.namespace]: data }); 19 | }); 20 | } 21 | 22 | remove(key) { 23 | return this.set(key, ''); 24 | } 25 | } 26 | 27 | export default Storage; 28 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/index.js: -------------------------------------------------------------------------------- 1 | import Oauth2 from './Oauth2'; 2 | export default Oauth2; 3 | export { default as enhancedFetch } from './utils/enhancedFetch'; 4 | export { default as Storage } from './Storage'; 5 | export { default as HttpError } from './HttpError'; 6 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/utils/enhancedFetch.js: -------------------------------------------------------------------------------- 1 | import HttpError from '../HttpError'; 2 | 3 | export default (url, request) => 4 | fetch(url, request).then(res => { 5 | if (res.ok) { 6 | return res.json(); 7 | } 8 | return res.text().then(body => { 9 | let err; 10 | try { 11 | err = JSON.parse(body); 12 | } catch (e) { 13 | err = body; 14 | } 15 | 16 | throw new HttpError(res.status, err); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/utils/generateRandomString.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | const array = new Uint32Array(28); 3 | window.crypto.getRandomValues(array); 4 | return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join(''); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/utils/getParamsFromCallbackUrl.js: -------------------------------------------------------------------------------- 1 | export default url => { 2 | const parsedUrl = new URL(url); 3 | return parsedUrl.hash 4 | .substring(1) 5 | .split('&') 6 | .reduce((params, part) => { 7 | const parts = part.split('='); 8 | params[parts[0]] = parts[1]; 9 | return params; 10 | }, {}); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/utils/getRedirectUrl.js: -------------------------------------------------------------------------------- 1 | export default provider => { 2 | return `${browser.identity.getRedirectURL()}${provider}`; 3 | }; 4 | -------------------------------------------------------------------------------- /packages/browser-extension-oauth2/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as enhancedFetch } from './enhancedFetch'; 2 | export { default as generateRandomString } from './generateRandomString'; 3 | export { default as getParamsFromCallbackUrl } from './getParamsFromCallbackUrl'; 4 | export { default as getRedirectUrl } from './getRedirectUrl'; 5 | -------------------------------------------------------------------------------- /packages/common/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "plugin:react/recommended" 7 | ], 8 | "plugins": [ 9 | "react-hooks" 10 | ], 11 | "rules": { 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "error" 14 | }, 15 | "globals": { 16 | "browser": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/common/components/Alerts/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Button from '@material-ui/core/Button'; 6 | import Dialog from '@material-ui/core/Dialog'; 7 | import DialogActions from '@material-ui/core/DialogActions'; 8 | import DialogContent from '@material-ui/core/DialogContent'; 9 | import DialogContentText from '@material-ui/core/DialogContentText'; 10 | import DialogTitle from '@material-ui/core/DialogTitle'; 11 | 12 | const useStyles = makeStyles({ 13 | contentText: { fontSize: 14 }, 14 | buttonLabel: { fontSize: 14 } 15 | }); 16 | 17 | const Alerts = () => { 18 | const { t } = useTranslation('alert'); 19 | const classes = useStyles(); 20 | const { open, title, content, onConfirm } = useStoreState( 21 | state => state.alerts 22 | ); 23 | const { hide } = useStoreActions(actions => actions.alerts); 24 | 25 | const handleConfirm = () => { 26 | onConfirm(); 27 | hide(); 28 | }; 29 | 30 | const handleClose = () => hide(false); 31 | 32 | return ( 33 | 39 | {title && {title}} 40 | 41 | 45 | {content} 46 | 47 | 48 | 49 | 56 | {onConfirm && ( 57 | 65 | )} 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default Alerts; 72 | -------------------------------------------------------------------------------- /packages/common/components/IconButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Tooltip from '@material-ui/core/Tooltip'; 6 | 7 | const sizeMap = { 8 | small: 18, 9 | medium: 24, 10 | large: 30 11 | }; 12 | 13 | const StyledContainer = styled.div` 14 | width: ${({ size }) => sizeMap[size]}px; 15 | height: ${({ size }) => sizeMap[size]}px; 16 | display: block; 17 | cursor: pointer; 18 | color: ${props => props.color}; 19 | padding: 0 2px; 20 | 21 | & svg { 22 | width: ${({ size }) => sizeMap[size]}px; 23 | height: ${({ size }) => sizeMap[size]}px; 24 | } 25 | `; 26 | 27 | const useStyles = makeStyles({ 28 | tooltip: { fontSize: '10px' } 29 | }); 30 | 31 | const IconButton = ({ size, color, tooltip, children, onClick }) => { 32 | const classes = useStyles(); 33 | 34 | if (tooltip) { 35 | return ( 36 | 37 | 38 | {children} 39 | 40 | 41 | ); 42 | } else { 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | } 49 | }; 50 | 51 | IconButton.propTypes = { 52 | size: PropTypes.oneOf(['small', 'medium', 'large']), 53 | color: PropTypes.string, 54 | tooltip: PropTypes.string, 55 | children: PropTypes.element.isRequired, 56 | onClick: PropTypes.func.isRequired 57 | }; 58 | 59 | IconButton.defaultProps = { 60 | size: 'medium', 61 | color: '#000000' 62 | }; 63 | 64 | export default IconButton; 65 | -------------------------------------------------------------------------------- /packages/common/components/MarkdownViewer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { StyledMarkdownBody } from './styled'; 4 | import Markdown from '../../services/markdown'; 5 | 6 | const MarkdownViewer = ({ content = '' }) => { 7 | return ( 8 | 12 | ); 13 | }; 14 | 15 | MarkdownViewer.propTypes = { 16 | content: PropTypes.string.isRequired 17 | }; 18 | 19 | export default MarkdownViewer; 20 | -------------------------------------------------------------------------------- /packages/common/components/TagDialog/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useStoreState, useStoreActions } from 'easy-peasy'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { 6 | Grid, 7 | Dialog, 8 | DialogContent, 9 | TextField, 10 | Chip 11 | } from '@material-ui/core'; 12 | import { APP_ID } from '../../constants'; 13 | 14 | const TagDialog = ({ tags, onAddTag, onRemoveTag }) => { 15 | const { t } = useTranslation('tagdialog'); 16 | const { 17 | tagDialog: { open } 18 | } = useStoreState(state => state); 19 | const { 20 | tagDialog: { setOpen } 21 | } = useStoreActions(actions => actions); 22 | const [input, setInput] = useState(''); 23 | const containerRef = useRef(null); 24 | 25 | useEffect(() => { 26 | containerRef.current = document.getElementById(APP_ID); 27 | }, []); 28 | 29 | const handleClose = () => { 30 | setOpen(false); 31 | }; 32 | 33 | const handleInputChange = e => { 34 | setInput(e.target.value); 35 | }; 36 | 37 | const handleKeyPress = e => { 38 | if (e.key === 'Enter') { 39 | onAddTag(input); 40 | setInput(''); 41 | } 42 | }; 43 | 44 | const handleDelete = tag => { 45 | onRemoveTag(tag); 46 | }; 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | 60 | 61 | 62 | {tags.map(tag => ( 63 | 64 | 69 | 70 | ))} 71 | 72 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | TagDialog.propTypes = { 79 | tags: PropTypes.array.isRequired, 80 | onAddTag: PropTypes.func.isRequired, 81 | onRemoveTag: PropTypes.func.isRequired 82 | }; 83 | 84 | export default TagDialog; 85 | -------------------------------------------------------------------------------- /packages/common/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Alerts } from './Alerts'; 2 | export { default as IconButton } from './IconButton'; 3 | export { default as MarkdownEditor } from './MarkdownEditor'; 4 | export { default as MarkdownViewer } from './MarkdownViewer'; 5 | export { default as TagDialog } from './TagDialog'; 6 | -------------------------------------------------------------------------------- /packages/common/constants.js: -------------------------------------------------------------------------------- 1 | export const APP_ID = 'yi-note'; 2 | export const PAGE = 'page'; 3 | 4 | export const WEBSITE_URL = 'https://www.yinote.co'; 5 | export const INSTALLATION_URL = `${WEBSITE_URL}/#installation`; 6 | export const GITHUB_URL = 'https://github.com/shuowu/yi-note'; 7 | export const FAQ_URL = `${WEBSITE_URL}/#faqs`; 8 | export const ISSUE_URL = `${GITHUB_URL}/issues`; 9 | 10 | export const TYPE_BOOKMARKS = 'bookmarks'; 11 | export const TYPE_NOTES = 'notes'; 12 | 13 | export const TYPE_VIDEO_NOTE = 'video'; 14 | 15 | export const PROVIDER_YOUTUBE = 'youtube'; 16 | 17 | export const NODE_ENV_PLAYGROUND = 'playground'; 18 | 19 | export const QUERY_AUTO_JUMP = 'yinotetimestamp'; 20 | 21 | export const KEY_VIDEO_SEEK_SECONDS = 'video_seek_seconds'; 22 | export const KEY_APPLY_SEEK_SEC_ON_URL = 'apply_seek_sec_on_url'; 23 | export const KEY_SCREENSHOT_RESOLUTION = 'screenshot_resolution'; 24 | export const KEY_RELOAD_TAB = 'reload_tab'; 25 | export const KEY_RELOAD_TAB_ALLOWED_DOMAINS = 'reload_tab_allowed_domains'; 26 | export const KEY_PAUSE_VIDEO_WHEN_EDITING = 'pause_video_when_editing'; 27 | 28 | export const SCREENSHOT_RESOLUTION = { 29 | 360: { x: 640, y: 360 }, 30 | 720: { x: 1280, y: 720 } 31 | }; 32 | 33 | export const REST_BASE_URL = 34 | process.env.NODE_ENV === 'production' 35 | ? process.env.REST_BASE_URL_PROD 36 | : process.env.REST_BASE_URL_DEV; 37 | -------------------------------------------------------------------------------- /packages/common/fonts/msyh.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/common/fonts/msyh.ttf -------------------------------------------------------------------------------- /packages/common/icons/evernote.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/common/icons/googledocs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /packages/common/icons/index.js: -------------------------------------------------------------------------------- 1 | export { default as EvernoteIcon } from './evernote.svg'; 2 | export { default as GoogleDocsIcon } from './googledocs.svg'; 3 | export { default as MarkdownIcon } from './markdown.svg'; 4 | export { default as OneNoteIcon } from './onenote.svg'; 5 | export { default as PatreonIcon } from './patreon.svg'; 6 | export { default as PaypalIcon } from './paypal.svg'; 7 | -------------------------------------------------------------------------------- /packages/common/icons/markdown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/common/icons/onenote.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/common/icons/patreon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/common/icons/paypal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/common/index.js: -------------------------------------------------------------------------------- 1 | export { default as withTheme } from './withTheme'; 2 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/common", 3 | "version": "1.0.0", 4 | "description": "YiNote extension internal common module", 5 | "main": "./src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "peerDependencies": { 10 | "@material-ui/core": "^4.9.12", 11 | "@material-ui/icons": "^4.5.1", 12 | "easy-peasy": "^3.3.0", 13 | "prop-types": "^15.7.2", 14 | "react": "^16.13.1", 15 | "react-dom": "^16.13.1", 16 | "react-i18next": "^11.2.6", 17 | "styled-components": "^5.0.0-rc.2" 18 | }, 19 | "dependencies": { 20 | "@yi-note/browser-extension-oauth2": "^1.1.0", 21 | "atob": "^2.1.2", 22 | "buffer-from": "^1.1.1", 23 | "compose-function": "^3.0.3", 24 | "deepmerge": "^4.2.2", 25 | "evernote": "^2.0.5", 26 | "file-saver": "^2.0.2", 27 | "js-logger": "^1.6.0", 28 | "js-video-url-parser": "^0.4.0", 29 | "jspdf": "^1.5.3", 30 | "marked": "^1.1.0", 31 | "md5": "^2.2.1", 32 | "page-metadata-parser": "^1.1.4", 33 | "uuid": "^8.3.2", 34 | "uuidv4": "^6.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/common/services/file.js: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver'; 2 | import { getVersion } from '../utils'; 3 | 4 | export const readAsJson = file => { 5 | return new Promise((resolve, reject) => { 6 | // eslint-disable-next-line no-undef 7 | const reader = new FileReader(); 8 | reader.readAsText(file); 9 | 10 | reader.onload = event => { 11 | const { result } = event.target; 12 | try { 13 | resolve(JSON.parse(result)); 14 | } catch (e) { 15 | reject(e); 16 | } 17 | }; 18 | 19 | reader.onerror = err => { 20 | reject(err); 21 | }; 22 | }); 23 | }; 24 | 25 | export const exportJsonFile = async (data, filename, version) => { 26 | version = version || getVersion(); 27 | const dataToExport = { version, data }; 28 | 29 | // eslint-disable-next-line no-undef 30 | const blob = new Blob([JSON.stringify(dataToExport)], { 31 | type: 'text/json;charset=utf-8' 32 | }); 33 | await exportFile(blob, filename); 34 | }; 35 | 36 | export const exportMarkdownFile = async (data, filename) => { 37 | // eslint-disable-next-line no-undef 38 | const blob = new Blob([data], { 39 | type: 'text/markdown;charset=utf-8' 40 | }); 41 | await exportFile(blob, filename); 42 | }; 43 | 44 | export const exportFile = async (blob, filename) => { 45 | const manifest = await browser.runtime.getManifest(); 46 | if (typeof browser !== 'undefined' && manifest.browser === 'firefox') { 47 | browser.runtime.sendMessage({ 48 | action: 'export-file', 49 | blob, 50 | filename 51 | }); 52 | } else { 53 | saveAs(blob, filename); 54 | } 55 | }; 56 | 57 | export default { 58 | readAsJson, 59 | exportJsonFile, 60 | exportMarkdownFile, 61 | exportFile 62 | }; 63 | -------------------------------------------------------------------------------- /packages/common/services/index.js: -------------------------------------------------------------------------------- 1 | export { default as integration } from './integration'; 2 | export { default as pdf } from './pdf'; 3 | export { default as markdown } from './markdown'; 4 | export { default as file } from './file'; 5 | export { default as storage } from './storage'; 6 | -------------------------------------------------------------------------------- /packages/common/services/integration/evernote/generator.js: -------------------------------------------------------------------------------- 1 | import EvernoteSDK from 'evernote'; 2 | import md5 from 'md5'; 3 | import { getBinaryFromBase64 } from '../utils'; 4 | import { secondsToTime, buildAutoSeekUrl } from '../../../utils'; 5 | import { INSTALLATION_URL } from '../../../constants'; 6 | 7 | const escape = url => { 8 | var tagsToReplace = { 9 | '&': '&', 10 | '<': '<', 11 | '>': '>' 12 | }; 13 | return url.replace(/[&<>]/g, tag => { 14 | return tagsToReplace[tag] || tag; 15 | }); 16 | }; 17 | 18 | class Generator { 19 | constructor(data) { 20 | this.data = data; 21 | } 22 | 23 | generatePayload(notebook) { 24 | const { meta: { title, description, url } = {}, notes = [] } = this.data; 25 | let nBody = 26 | ''; 27 | nBody += ''; 28 | nBody += `${browser.i18n.getMessage( 29 | 'services_template_signature', 30 | `YiNote` 31 | )}`; 32 | nBody += '
'; 33 | if (description) { 34 | nBody += `
${description}
`; 35 | } 36 | nBody += '
'; 37 | const resources = []; 38 | notes.forEach(note => { 39 | const timestampedUrl = buildAutoSeekUrl(url, note.timestamp); 40 | if (note.image) { 41 | // Convert image to binary, then save as Evernote resource 42 | const fileMime = 'image/jpeg'; 43 | const fileName = `${note.timestamp}.jpeg`; 44 | const binaryResource = getBinaryFromBase64(note.image); 45 | const md5Hash = md5(binaryResource); 46 | const resourceAttributes = new EvernoteSDK.Types.ResourceAttributes({ 47 | fileName 48 | }); 49 | const resource = new EvernoteSDK.Types.Resource({ 50 | data: new EvernoteSDK.Types.Data({ body: binaryResource }), 51 | mime: fileMime, 52 | attributes: resourceAttributes 53 | }); 54 | resources.push(resource); 55 | 56 | nBody += ``; 57 | } 58 | nBody += `
${secondsToTime(note.timestamp)}

${ 61 | note.content 62 | }

`; 63 | }); 64 | 65 | nBody += '
'; 66 | 67 | // Create note object 68 | const note = new EvernoteSDK.Types.Note(); 69 | note.notebookGuid = notebook.guid; 70 | note.title = title || browser.i18n.getMessage('title_default'); 71 | note.content = nBody; 72 | note.resources = resources; 73 | 74 | return note; 75 | } 76 | } 77 | 78 | export default Generator; 79 | -------------------------------------------------------------------------------- /packages/common/services/integration/evernote/index.js: -------------------------------------------------------------------------------- 1 | import EvernoteSDK from 'evernote'; 2 | import { enhancedFetch } from '@yi-note/browser-extension-oauth2'; 3 | import { getRedirectUrl } from '../utils'; 4 | import Service from '../service'; 5 | import Generator from './generator'; 6 | import { REST_BASE_URL } from '../../../constants'; 7 | 8 | class Evernote extends Service { 9 | constructor(namespace, data) { 10 | super(namespace, data); 11 | this.generator = new Generator(data); 12 | } 13 | 14 | getAccessToken() { 15 | return this.storage.get(Service.KEY_ACCESS_TOKEN).then(accessToken => { 16 | if (accessToken) { 17 | return accessToken; 18 | } 19 | // OAuth flow to get accessToken 20 | const redirectUrl = getRedirectUrl(this.namespace); 21 | return enhancedFetch( 22 | `${REST_BASE_URL}/evernote/authorize-url?redirect_url=${encodeURIComponent( 23 | redirectUrl 24 | )}` 25 | ).then(data => { 26 | const { oauthUrl, oauthToken, oauthSecret } = data; 27 | return browser.identity 28 | .launchWebAuthFlow({ interactive: true, url: oauthUrl }) 29 | .then(url => { 30 | const parsedUrl = new URL(url); 31 | const params = new URLSearchParams(parsedUrl.search); 32 | const verifier = params.get('oauth_verifier'); 33 | return enhancedFetch(`${REST_BASE_URL}/evernote/access-token`, { 34 | method: 'POST', 35 | body: JSON.stringify({ oauthToken, oauthSecret, verifier }) 36 | }).then(({ accessToken }) => { 37 | return this.storage 38 | .set(Service.KEY_ACCESS_TOKEN, accessToken) 39 | .then(() => accessToken); 40 | }); 41 | }); 42 | }); 43 | }); 44 | } 45 | 46 | sendNotes() { 47 | return this.getAccessToken() 48 | .then(token => { 49 | const client = new EvernoteSDK.Client({ token, sandbox: false }); 50 | const store = client.getNoteStore(); 51 | return store 52 | .listNotebooks() 53 | .then(notebooks => { 54 | const yinotebook = notebooks.find( 55 | notebook => notebook.name === Service.YI_NOTEBOOK_NAME 56 | ); 57 | if (yinotebook) { 58 | return yinotebook; 59 | } 60 | return store.createNotebook({ name: Service.YI_NOTEBOOK_NAME }); 61 | }) 62 | .then(notebook => { 63 | const notePayload = this.generator.generatePayload(notebook); 64 | return store.createNote(notePayload).then(note => { 65 | return this.storage.set(this.data.id, note.guid).then(() => note); 66 | }); 67 | }); 68 | }) 69 | .catch(err => { 70 | if (err.errorCode === EvernoteSDK.Errors.EDAMErrorCode.AUTH_EXPIRED) { 71 | return this.storage 72 | .remove(Service.KEY_ACCESS_TOKEN) 73 | .then(() => this.sendNotes()); 74 | } 75 | throw err; 76 | }); 77 | } 78 | } 79 | 80 | export default Evernote; 81 | -------------------------------------------------------------------------------- /packages/common/services/integration/index.js: -------------------------------------------------------------------------------- 1 | import Evernote from './evernote'; 2 | import Onenote from './onenote'; 3 | import Googledocs from './googledocs'; 4 | 5 | export default { 6 | Evernote, 7 | Onenote, 8 | Googledocs 9 | }; 10 | -------------------------------------------------------------------------------- /packages/common/services/integration/onenote/generator.js: -------------------------------------------------------------------------------- 1 | import { secondsToTime, buildAutoSeekUrl } from '../../../utils'; 2 | import { INSTALLATION_URL } from '../../../constants'; 3 | 4 | class Generator { 5 | constructor(data) { 6 | this.data = data; 7 | } 8 | 9 | generatePayload() { 10 | const { meta: { title, description, url } = {}, notes = [] } = this.data; 11 | 12 | return ` 13 | 14 | 15 | 16 | ${title} 17 | 18 | 19 | 20 | ${browser.i18n.getMessage( 21 | 'services_template_signature', 22 | `YiNote` 23 | )} 24 | 25 |

${browser.i18n.getMessage( 26 | 'services_template_description' 27 | )}${description}

28 |
29 | ${notes.map(note => { 30 | return ` 31 |
32 | 33 | 34 | 35 | ${secondsToTime(note.timestamp)} 36 | 37 | 38 |

${note.content}

39 |
40 | `; 41 | })} 42 |
43 | 44 | 45 | `; 46 | } 47 | } 48 | 49 | export default Generator; 50 | -------------------------------------------------------------------------------- /packages/common/services/integration/onenote/index.js: -------------------------------------------------------------------------------- 1 | import Oauth2 from '@yi-note/browser-extension-oauth2'; 2 | import Service from '../service'; 3 | import Generator from './generator'; 4 | 5 | class OneNote extends Service { 6 | constructor(namespace, data) { 7 | super(namespace, data); 8 | 9 | const manifest = browser.runtime.getManifest(); 10 | const clientId = 11 | manifest.browser === 'firefox' 12 | ? '5a06bf8d-6526-4b65-a85b-221f6dde2639' 13 | : '24fa7402-009b-4526-b067-e6de468fbcc0'; 14 | this.oauth2 = new Oauth2({ 15 | provider: this.namespace, 16 | authorization_endpoint: 17 | 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', 18 | client_id: clientId, 19 | scopes: ['notes.create'], 20 | api_base_url: 'https://graph.microsoft.com/v1.0/me/onenote' 21 | }); 22 | this.generator = new Generator(data); 23 | } 24 | 25 | async sendNotes() { 26 | const { id, meta, notes } = this.data; 27 | const { value: notebooks } = await this.oauth2.callApi('/notebooks', { 28 | mode: 'cors', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | } 32 | }); 33 | let notebook = notebooks.find( 34 | ({ displayName }) => displayName === Service.YI_NOTEBOOK_NAME 35 | ); 36 | if (!notebook) { 37 | notebook = await this.oauth2.callApi('/notebooks', { 38 | method: 'POST', 39 | mode: 'cors', 40 | headers: { 41 | 'Content-Type': 'application/json' 42 | }, 43 | body: JSON.stringify({ displayName: Service.YI_NOTEBOOK_NAME }) 44 | }); 45 | } 46 | const { value: sections } = await this.oauth2.callApi('/sections'); 47 | let section = sections.find(({ displayName }) => displayName === 'YiNote'); 48 | if (!section) { 49 | section = await this.oauth2.callApi( 50 | `/notebooks/${notebook.id}/sections`, 51 | { 52 | method: 'POST', 53 | mode: 'cors', 54 | headers: { 55 | 'Content-Type': 'application/json' 56 | }, 57 | body: JSON.stringify({ displayName: 'YiNote' }) 58 | } 59 | ); 60 | } 61 | const note = await this.oauth2.callApi(`/sections/${section.id}/pages`, { 62 | method: 'POST', 63 | mode: 'cors', 64 | headers: { 65 | 'Content-Type': 'text/html' 66 | }, 67 | body: this.generator.generatePayload(meta, notes) 68 | }); 69 | await this.storage.set(id, note.id); 70 | return note; 71 | } 72 | } 73 | 74 | export default OneNote; 75 | -------------------------------------------------------------------------------- /packages/common/services/integration/service.js: -------------------------------------------------------------------------------- 1 | import { Storage } from '@yi-note/browser-extension-oauth2'; 2 | 3 | class Service { 4 | static KEY_ACCESS_TOKEN = 'accessToken'; 5 | static YI_NOTEBOOK_NAME = 'YiNotebook'; 6 | 7 | constructor(namespace, data) { 8 | this.data = data; 9 | this.namespace = namespace; 10 | this.storage = new Storage(namespace); 11 | } 12 | 13 | sendNotes() { 14 | return Promise.reject('Method not implemented'); 15 | } 16 | 17 | getExistingId() { 18 | return this.storage.get(this.data.id); 19 | } 20 | } 21 | 22 | export default Service; 23 | -------------------------------------------------------------------------------- /packages/common/services/integration/utils.js: -------------------------------------------------------------------------------- 1 | import bufferFrom from 'buffer-from'; 2 | import atob from 'atob'; 3 | 4 | export const getRedirectUrl = provider => { 5 | return `${browser.identity.getRedirectURL()}${provider}`; 6 | }; 7 | 8 | export const getBinaryFromBase64 = encodedUri => { 9 | return bufferFrom(encodedUri.split(',')[1], 'base64'); 10 | }; 11 | 12 | export const b64toBlob = (b64Data, contentType = '', sliceSize = 512) => { 13 | const byteCharacters = atob(b64Data); 14 | const byteArrays = []; 15 | 16 | for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { 17 | const slice = byteCharacters.slice(offset, offset + sliceSize); 18 | 19 | const byteNumbers = new Array(slice.length); 20 | for (let i = 0; i < slice.length; i++) { 21 | byteNumbers[i] = slice.charCodeAt(i); 22 | } 23 | 24 | // eslint-disable-next-line no-undef 25 | const byteArray = new Uint8Array(byteNumbers); 26 | byteArrays.push(byteArray); 27 | } 28 | 29 | // eslint-disable-next-line no-undef 30 | const blob = new Blob(byteArrays, { type: contentType }); 31 | return blob; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/common/services/markdown.js: -------------------------------------------------------------------------------- 1 | import marked from 'marked'; 2 | import { secondsToTime, buildAutoSeekUrl } from '../utils'; 3 | import { INSTALLATION_URL } from '../constants'; 4 | 5 | class Markdown { 6 | static toText(markdownContent) { 7 | const div = document.createElement('div'); 8 | div.innerHTML = marked(markdownContent); 9 | return div.innerText; 10 | } 11 | 12 | static toHTML(markdownContent) { 13 | return marked(markdownContent); 14 | } 15 | 16 | static pagesToMarkdown(pages) { 17 | let data = `\n\n`; 21 | 22 | for (let page of pages) { 23 | const { meta, notes } = page; 24 | data += `# [${meta.title}](${meta.url})\n\n`; 25 | 26 | for (let note of notes) { 27 | data += `## [${secondsToTime(note.timestamp)}](${buildAutoSeekUrl( 28 | meta.url, 29 | note.timestamp 30 | )})\n\n`; 31 | data += note.content + '\n\n'; 32 | } 33 | } 34 | 35 | return data; 36 | } 37 | } 38 | 39 | export default Markdown; 40 | -------------------------------------------------------------------------------- /packages/common/services/pdf/index.js: -------------------------------------------------------------------------------- 1 | import jsPDF from 'jspdf'; 2 | import { secondsToTime, buildAutoSeekUrl, getFileUrl } from '../../utils'; 3 | import StorageService from '../storage'; 4 | import { 5 | WEBSITE_URL, 6 | KEY_APPLY_SEEK_SEC_ON_URL, 7 | KEY_VIDEO_SEEK_SECONDS 8 | } from '../../constants'; 9 | import msyh from '../../fonts/msyh.ttf'; 10 | 11 | export default class PDFGenerator { 12 | constructor() { 13 | this.doc = new jsPDF(); 14 | } 15 | 16 | static init() { 17 | fetch(getFileUrl(msyh)) 18 | .then(res => res.blob()) 19 | .then(blob => { 20 | const reader = new FileReader(); 21 | reader.readAsDataURL(blob); 22 | reader.onloadend = () => { 23 | const font = reader.result.split(',')[1]; 24 | jsPDF.API.events.push([ 25 | 'addFonts', 26 | function() { 27 | this.addFileToVFS('msyh-normal.ttf', font); 28 | this.addFont('msyh-normal.ttf', 'msyh', 'normal'); 29 | } 30 | ]); 31 | }; 32 | }); 33 | } 34 | 35 | async getBlobOutput({ url, title, notes }) { 36 | // TODO: pass in options instead of use settings from storage 37 | const settings = await StorageService.getStorage().getSettings(); 38 | const seekSeconds = +settings[KEY_VIDEO_SEEK_SECONDS] || 0; 39 | const shouldApplySeekSecondsOnUrl = settings[KEY_APPLY_SEEK_SEC_ON_URL]; 40 | 41 | this.doc.setFont('msyh'); 42 | this.doc.setFontType('normal'); 43 | let y = 20; 44 | 45 | if (title) { 46 | this.doc.setFontSize(18); 47 | this.doc.text(20, y, this.doc.splitTextToSize(title, 180)); 48 | y += Math.ceil(title.length / 50) * 18; 49 | } 50 | 51 | this.doc.setFontSize(12); 52 | this.doc.text(20, y, 'Generated from '); 53 | this.doc.setTextColor(71, 99, 255); 54 | this.doc.textWithLink('YiNote', 53, y, { url: WEBSITE_URL }); 55 | y += 10; 56 | this.doc.setTextColor(0, 0, 0); 57 | 58 | this.doc.setFontSize(14); 59 | this.doc.text(20, y, '-- Notes --'); 60 | y += 10; 61 | this.doc.setFontSize(12); 62 | 63 | for (const note of notes) { 64 | let content = note.content; 65 | content = this.doc.splitTextToSize(content, 180); 66 | if (y + 66 + 6 + 6 * content.length > 300) { 67 | this.doc.addPage(); 68 | y = 20; 69 | } 70 | if (note.image) { 71 | this.doc.addImage(note.image, 'PNG', 20, y, 160, 90, null, 'NONE'); 72 | y += 100; 73 | } 74 | 75 | this.doc.setTextColor(71, 99, 255); 76 | this.doc.textWithLink(secondsToTime(note.timestamp), 20, y, { 77 | url: buildAutoSeekUrl( 78 | url, 79 | shouldApplySeekSecondsOnUrl 80 | ? note.timestamp - seekSeconds 81 | : note.timestamp 82 | ) 83 | }); 84 | 85 | this.doc.setTextColor(0, 0, 0); 86 | y += 6; 87 | this.doc.text(20, y, content); 88 | y += 6 * content.length; 89 | } 90 | 91 | return this.doc.output('blob'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/common/services/storage/Storage.js: -------------------------------------------------------------------------------- 1 | export default class Storage { 2 | getPage() { 3 | return Promise.reject('Method need to be implemented: getPage'); 4 | } 5 | 6 | addPage() { 7 | return Promise.reject('Method need to be implemented: addPage'); 8 | } 9 | 10 | removePage() { 11 | return Promise.reject('Method need to be implemented: removePage'); 12 | } 13 | 14 | addNote() { 15 | return Promise.reject('Method need to be implemented: addNote'); 16 | } 17 | 18 | updateNote() { 19 | return Promise.reject('Method need to be implemented: updateNote'); 20 | } 21 | 22 | removeNote() { 23 | return Promise.reject('Method need to be implemented: removeNote'); 24 | } 25 | 26 | getNotes() { 27 | return Promise.reject('Method need to be implemented: getNotes'); 28 | } 29 | 30 | addTag() { 31 | return Promise.reject('Method need to be implemented: addTag'); 32 | } 33 | 34 | removeTag() { 35 | return Promise.reject('Method need to be implemented: removeTag'); 36 | } 37 | 38 | getTags() { 39 | return Promise.reject('Method need to be implemented: getTags'); 40 | } 41 | 42 | getBookmarks() { 43 | return Promise.reject('Method need to be implemented: getBookmarks'); 44 | } 45 | 46 | searchBookmarks() { 47 | return Promise.reject('Method need to be implemented: searchBookmarks'); 48 | } 49 | 50 | filterBookmarksByTags() { 51 | return Promise.reject( 52 | 'Method need to be implemented: filterBookmarksByTags' 53 | ); 54 | } 55 | 56 | searchNotes() { 57 | return Promise.reject('Method need to be implemented: searchNotes'); 58 | } 59 | 60 | getServiceData() { 61 | return Promise.reject('Method need to be implemented: getServiceData'); 62 | } 63 | 64 | getSettings() { 65 | return Promise.reject('Method need to be implemented: getSettings'); 66 | } 67 | 68 | setSetting() { 69 | return Promise.reject('Method need to be implemented: setSetting'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/common/services/storage/StorageFactory.js: -------------------------------------------------------------------------------- 1 | import LocalStorage from './LocalStorage'; 2 | import BrowserStorage from './BrowserStorage'; 3 | import { NODE_ENV_PLAYGROUND } from '../../constants'; 4 | 5 | export default class StorageFactory { 6 | constructor() {} 7 | 8 | static #storage; 9 | 10 | static getStorage(type) { 11 | // eslint-disable-next-line no-undef 12 | if (process.env.NODE_ENV === NODE_ENV_PLAYGROUND) { 13 | return new LocalStorage(); 14 | } 15 | 16 | switch (type) { 17 | default: 18 | return new BrowserStorage(); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/services/storage/index.js: -------------------------------------------------------------------------------- 1 | import StorageFactory from './StorageFactory'; 2 | export default StorageFactory; 3 | -------------------------------------------------------------------------------- /packages/common/store/alerts.js: -------------------------------------------------------------------------------- 1 | import { action } from 'easy-peasy'; 2 | 3 | const alertsModel = { 4 | open: false, 5 | content: '', 6 | onConfirm: null, 7 | show: action((state, { title, content, onConfirm }) => { 8 | state.open = true; 9 | if (title) { 10 | state.title = title; 11 | } 12 | if (content) { 13 | state.content = content; 14 | } 15 | if (onConfirm) { 16 | state.onConfirm = onConfirm; 17 | } 18 | }), 19 | hide: action(state => { 20 | state.open = false; 21 | state.title = ''; 22 | state.content = ''; 23 | state.onConfirm = null; 24 | }) 25 | }; 26 | 27 | export default alertsModel; 28 | -------------------------------------------------------------------------------- /packages/common/store/index.js: -------------------------------------------------------------------------------- 1 | export { default as page } from './page'; 2 | export { default as settings } from './settings'; 3 | export { default as alerts } from './alerts'; 4 | export { default as tagDialog } from './tagDialog'; 5 | -------------------------------------------------------------------------------- /packages/common/store/settings.js: -------------------------------------------------------------------------------- 1 | import { action, thunk } from 'easy-peasy'; 2 | import { storage as StorageService } from '../services'; 3 | 4 | const storage = StorageService.getStorage(); 5 | 6 | const settingsModel = { 7 | data: {}, 8 | setSettings: action((state, payload) => { 9 | state.data = { ...payload }; 10 | }), 11 | fetchSettings: thunk(async actions => { 12 | const settings = (await storage.getSettings()) || {}; 13 | actions.setSettings(settings); 14 | }), 15 | setSetting: thunk(async (actions, setting) => { 16 | const settings = await storage.setSetting(setting); 17 | actions.setSettings(settings); 18 | }) 19 | }; 20 | 21 | export default settingsModel; 22 | -------------------------------------------------------------------------------- /packages/common/store/tagDialog.js: -------------------------------------------------------------------------------- 1 | import { action } from 'easy-peasy'; 2 | 3 | const tagDialogModel = { 4 | open: false, 5 | setOpen: action((state, payload) => { 6 | state.open = payload; 7 | }) 8 | }; 9 | 10 | export default tagDialogModel; 11 | -------------------------------------------------------------------------------- /packages/common/utils/addNoteToList.js: -------------------------------------------------------------------------------- 1 | export default (notes, note) => { 2 | return [...notes.filter(n => n.id !== note.id), note].sort( 3 | (note1, note2) => note1.timestamp - note2.timestamp 4 | ); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/common/utils/addTagToList.js: -------------------------------------------------------------------------------- 1 | export default (tags, tag) => { 2 | const tagsSet = new Set([...tags, tag]); 3 | return Array.from(tagsSet); 4 | }; 5 | -------------------------------------------------------------------------------- /packages/common/utils/buildAutoSeekUrl.js: -------------------------------------------------------------------------------- 1 | import videoUrlParser from 'js-video-url-parser'; 2 | import { QUERY_AUTO_JUMP } from '../constants'; 3 | 4 | export default (url, timestamp) => { 5 | if (timestamp < 0) { 6 | timestamp = 0; 7 | } 8 | 9 | const { provider } = videoUrlParser.parse(url) || {}; 10 | const parsedUrl = new URL(url); 11 | const params = new URLSearchParams(parsedUrl.search); 12 | params.delete('t'); 13 | params.delete(QUERY_AUTO_JUMP); 14 | if (provider === 'youtube') { 15 | params.set('t', timestamp); 16 | } else { 17 | params.set(QUERY_AUTO_JUMP, timestamp); 18 | } 19 | parsedUrl.search = params.toString(); 20 | return parsedUrl.toString(); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/common/utils/capitalize.js: -------------------------------------------------------------------------------- 1 | export default s => { 2 | if (typeof s !== 'string') { 3 | return ''; 4 | } 5 | 6 | return s.charAt(0).toUpperCase() + s.slice(1); 7 | }; 8 | -------------------------------------------------------------------------------- /packages/common/utils/delay.js: -------------------------------------------------------------------------------- 1 | export default ms => new Promise(res => window.setTimeout(res, ms)); 2 | -------------------------------------------------------------------------------- /packages/common/utils/enhancedFetch.js: -------------------------------------------------------------------------------- 1 | class HttpError extends Error { 2 | constructor(status, error) { 3 | super(); 4 | 5 | if (Error.captureStackTrace) { 6 | Error.captureStackTrace(this, HttpError); 7 | } 8 | 9 | this.status = status; 10 | this.error = error; 11 | } 12 | } 13 | 14 | export default (url, request) => 15 | fetch(url, request).then(res => { 16 | if (res.ok) { 17 | return res.json(); 18 | } 19 | return res.text().then(body => { 20 | let err; 21 | try { 22 | err = JSON.parse(body); 23 | } catch (e) { 24 | err = body; 25 | } 26 | 27 | throw new HttpError(res.status, err); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/common/utils/generatePageId.js: -------------------------------------------------------------------------------- 1 | import { v5 } from 'uuid'; 2 | import compose from 'compose-function'; 3 | import videoUrlParser from 'js-video-url-parser'; 4 | import { PROVIDER_YOUTUBE } from '../constants'; 5 | import uuidNamespace from './uuid-namespace'; 6 | 7 | const getUrlWithoutHash = url => { 8 | const parsedUrl = new URL(url); 9 | parsedUrl.hash = ''; 10 | return parsedUrl.toString(); 11 | }; 12 | 13 | const getVideoInfo = url => { 14 | const info = videoUrlParser.parse(url) || {}; 15 | return { ...info, url }; 16 | }; 17 | 18 | export default url => { 19 | const { provider, id, url: urlWithoutHash } = compose( 20 | getVideoInfo, 21 | getUrlWithoutHash 22 | )(url); 23 | if (provider === PROVIDER_YOUTUBE) { 24 | return v5(`${provider}-${id}`, uuidNamespace); 25 | } 26 | 27 | return v5(urlWithoutHash, uuidNamespace); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/common/utils/getFileUrl.js: -------------------------------------------------------------------------------- 1 | export default path => { 2 | if (typeof browser !== 'undefined') { 3 | return browser.runtime.getURL(path); 4 | } 5 | return path; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/common/utils/getVersion.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | if (typeof browser === 'undefined') { 3 | return ''; 4 | } 5 | 6 | const { version } = browser.runtime.getManifest(); 7 | return version; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/common/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as retry } from './retry'; 2 | export { default as sendMessage } from './sendMessage'; 3 | export { default as delay } from './delay'; 4 | export { default as addNoteToList } from './addNoteToList'; 5 | export { default as addTagToList } from './addTagToList'; 6 | export { default as secondsToTime } from './secondsToTime'; 7 | export { default as getVersion } from './getVersion'; 8 | export { default as generatePageId } from './generatePageId'; 9 | export { default as buildAutoSeekUrl } from './buildAutoSeekUrl'; 10 | export { default as capitalize } from './capitalize'; 11 | export { default as getFileUrl } from './getFileUrl'; 12 | export { default as enhancedFetch } from './enhancedFetch'; 13 | -------------------------------------------------------------------------------- /packages/common/utils/retry.js: -------------------------------------------------------------------------------- 1 | export default ( 2 | predicate, 3 | resolve, 4 | reject = () => {}, 5 | maxTimes = 15, 6 | interval = 300 7 | ) => { 8 | if (predicate()) { 9 | resolve(); 10 | return; 11 | } 12 | 13 | let counter = 0; 14 | let timer = window.setInterval(() => { 15 | if (counter++ >= maxTimes) { 16 | window.clearInterval(timer); 17 | reject(); 18 | } 19 | 20 | if (predicate()) { 21 | resolve(); 22 | window.clearInterval(timer); 23 | } 24 | }, interval); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/common/utils/secondsToTime.js: -------------------------------------------------------------------------------- 1 | export default s => { 2 | var hours = Math.floor(s / 3600); 3 | var minutes = Math.floor((s - hours * 3600) / 60); 4 | var seconds = s - hours * 3600 - minutes * 60; 5 | var time = ''; 6 | 7 | if (hours != 0) { 8 | time = hours + ':'; 9 | } 10 | if (minutes != 0 || time !== '') { 11 | minutes = minutes < 10 && time !== '' ? '0' + minutes : String(minutes); 12 | time += minutes + ':'; 13 | } 14 | if (time === '') { 15 | time = (seconds < 10 ? '0:0' : '0:') + seconds; 16 | } else { 17 | time += seconds < 10 ? '0' + seconds : String(seconds); 18 | } 19 | 20 | return time; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/common/utils/sendMessage.js: -------------------------------------------------------------------------------- 1 | import { APP_ID, PAGE } from '../constants'; 2 | 3 | // Function for extension and host webpage communication 4 | export default (action, data, notFromExtension) => 5 | window.postMessage( 6 | { action, from: notFromExtension ? PAGE : APP_ID, ...data }, 7 | '*' 8 | ); 9 | -------------------------------------------------------------------------------- /packages/common/utils/uuid-namespace.js: -------------------------------------------------------------------------------- 1 | export default 'e1433c8f-bc34-431d-99b1-2a78abdd7f35'; 2 | -------------------------------------------------------------------------------- /packages/common/withTheme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import merge from 'deepmerge'; 3 | import { 4 | ThemeProvider as MuiThemeProvider, 5 | createMuiTheme 6 | } from '@material-ui/core/styles'; 7 | import { blue, red } from '@material-ui/core/colors'; 8 | import { ThemeProvider as StyledThemeProvider } from 'styled-components'; 9 | 10 | const muiTheme = createMuiTheme({ 11 | zIndex: { 12 | modal: 8000, 13 | tooltip: 8050 14 | }, 15 | palette: { 16 | primary: { ...red, main: red[700] }, 17 | secondary: blue 18 | } 19 | }); 20 | 21 | const styledTheme = { 22 | header: { 23 | height: 45 24 | }, 25 | footer: { 26 | height: 60 27 | }, 28 | panel: { 29 | width: 380, 30 | zIndex: 8000 31 | } 32 | }; 33 | 34 | const theme = merge(muiTheme, styledTheme); 35 | 36 | export default Component => () => ( 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | -------------------------------------------------------------------------------- /packages/extension/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "plugin:react/recommended" 7 | ], 8 | "plugins": [ 9 | "react-hooks" 10 | ], 11 | "rules": { 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "error" 14 | }, 15 | "globals": { 16 | "browser": "readonly" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/extension/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/icons/icon-128.png -------------------------------------------------------------------------------- /packages/extension/icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/icons/icon-16.png -------------------------------------------------------------------------------- /packages/extension/icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/icons/icon-48.png -------------------------------------------------------------------------------- /packages/extension/installed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/installed.png -------------------------------------------------------------------------------- /packages/extension/manifest.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "YiNote", 3 | "version": "1.7.0", 4 | "description": "Most efficient way to take & share time-stamped notes while watching videos!", 5 | "icons": { 6 | "16": "icons/icon-16.png", 7 | "48": "icons/icon-48.png", 8 | "128": "icons/icon-128.png" 9 | }, 10 | "browser_action": { 11 | "default_icon": "icons/icon-16.png" 12 | }, 13 | "manifest_version": 2, 14 | "default_locale": "en", 15 | "permissions": [ 16 | "identity", 17 | "storage", 18 | "unlimitedStorage" 19 | ], 20 | "background": { 21 | "scripts": [ 22 | "vendors/browser-polyfill.js", 23 | "background.js" 24 | ], 25 | "persistent": true 26 | }, 27 | "content_scripts": [ 28 | { 29 | "matches": [ 30 | "http://*/*", 31 | "https://*/*", 32 | "file://*/*" 33 | ], 34 | "js": [ 35 | "vendors/youtube-iframe-api.js", 36 | "vendors/embedly.js", 37 | "vendors/browser-polyfill.js", 38 | "content.js" 39 | ], 40 | "run_at": "document_end" 41 | } 42 | ], 43 | "web_accessible_resources": [ 44 | "installed.png", 45 | "images/facebook-48.png", 46 | "images/twitter-48.png", 47 | "images/copylink-48.png", 48 | "assets/fonts/msyh.ttf" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /packages/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/extension", 3 | "version": "1.7.0", 4 | "description": "Most efficient way to take & share time-stamped notes while watching videos!", 5 | "private": false, 6 | "keywords": [ 7 | "chrome", 8 | "extension", 9 | "elearning", 10 | "notes" 11 | ], 12 | "main": "src/index.js", 13 | "scripts": { 14 | "build:chromium": "webpack --env=chromium --config webpack.config.js", 15 | "build:firefox": "webpack --env=firefox --config webpack.config.js", 16 | "build": "npm run build:chromium && npm run build:firefox", 17 | "dev:chromium": "webpack --env=chromium --config webpack.dev.config.js", 18 | "dev:firefox": "webpack --env=firefox --config webpack.dev.config.js", 19 | "start:dev": "npm run dev:chromium" 20 | }, 21 | "engines": { 22 | "node": ">=10.0.0", 23 | "npm": ">=6.0.0" 24 | }, 25 | "author": "Shuo Wu", 26 | "license": "GNU GPLv3", 27 | "dependencies": { 28 | "@babel/runtime": "^7.7.4", 29 | "@material-ui/core": "^4.9.12", 30 | "@material-ui/icons": "^4.5.1", 31 | "@material-ui/lab": "^4.0.0-alpha.52", 32 | "@yi-note/common": "^1.0.0", 33 | "easy-peasy": "^3.3.0", 34 | "i18next": "^19.0.2", 35 | "i18next-browser-languagedetector": "^4.0.1", 36 | "js-logger": "^1.6.0", 37 | "js-video-url-parser": "^0.4.0", 38 | "markerjs": "^1.8.1", 39 | "prop-types": "^15.7.2", 40 | "react": "^16.13.1", 41 | "react-dom": "^16.13.1", 42 | "react-highlighter": "^0.4.3", 43 | "react-i18next": "^11.2.6", 44 | "react-recipes": "^1.0.0", 45 | "react-router-dom": "^5.1.2", 46 | "semver-compare": "^1.0.0", 47 | "styled-components": "^5.0.0-rc.2", 48 | "throttle-debounce": "^2.1.0", 49 | "uuidv4": "^6.0.0" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.7.4", 53 | "@babel/plugin-proposal-class-properties": "^7.7.4", 54 | "@babel/plugin-proposal-private-methods": "^7.8.3", 55 | "@babel/plugin-transform-runtime": "^7.7.4", 56 | "@babel/preset-env": "^7.7.4", 57 | "@babel/preset-react": "^7.7.4", 58 | "@quickbaseoss/babel-plugin-styled-components-css-namespace": "^1.0.0-rc4", 59 | "babel-loader": "^8.0.6", 60 | "babel-plugin-js-logger": "^1.0.17", 61 | "babel-plugin-styled-components": "^1.10.6", 62 | "clean-webpack-plugin": "^3.0.0", 63 | "copy-webpack-plugin": "^5.1.1", 64 | "css-loader": "^3.6.0", 65 | "dotenv-webpack": "^1.7.0", 66 | "eslint-plugin-react": "^7.16.0", 67 | "eslint-plugin-react-hooks": "^2.3.0", 68 | "file-loader": "^5.0.2", 69 | "filemanager-webpack-plugin": "^2.0.5", 70 | "html-webpack-plugin": "^4.2.1", 71 | "react-svg-loader": "^3.0.3", 72 | "style-loader": "^1.0.1", 73 | "uglify-js": "*", 74 | "web-ext": "^4.2.0", 75 | "webpack": "^4.43.0", 76 | "webpack-cli": "^3.3.10", 77 | "webpack-dev-server": "^3.11.0", 78 | "webpack-manifest-plugin": "^2.2.0", 79 | "webpack-merge": "^4.2.2" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/extension/src/background/index.js: -------------------------------------------------------------------------------- 1 | import Logger from 'js-logger'; 2 | import migration_v_0_6_4 from './migrations/0.6.4'; 3 | 4 | Logger.useDefaults(); 5 | 6 | browser.browserAction.onClicked.addListener(tab => { 7 | browser.tabs.sendMessage(tab.id, { action: 'togglePanel' }).catch(e => { 8 | logger.error(e); 9 | browser.runtime.openOptionsPage(); 10 | }); 11 | }); 12 | 13 | browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 14 | const openOptions = options => { 15 | let timer; 16 | let counter = 10; 17 | const sendMessage = () => { 18 | return browser.runtime 19 | .sendMessage(options) 20 | .then(() => { 21 | window.clearTimeout(timer); 22 | }) 23 | .catch(err => { 24 | window.clearTimeout(timer); 25 | if (counter-- === 0) { 26 | logger.error(err); 27 | return; 28 | } 29 | timer = window.setTimeout(sendMessage, 200); 30 | }); 31 | }; 32 | browser.runtime.openOptionsPage().then(sendMessage); 33 | }; 34 | 35 | const exportFile = () => { 36 | browser.downloads.download({ 37 | filename: message.filename, 38 | url: URL.createObjectURL(message.blob), 39 | saveAs: false 40 | }); 41 | }; 42 | 43 | const copyToClipboard = () => { 44 | const { data: text } = message; 45 | const copyFrom = document.createElement('textarea'); 46 | copyFrom.textContent = text; 47 | const body = document.getElementsByTagName('body')[0]; 48 | body.appendChild(copyFrom); 49 | copyFrom.select(); 50 | document.execCommand('copy'); 51 | body.removeChild(copyFrom); 52 | sendResponse({ code: 'success' }); 53 | }; 54 | 55 | const { action } = message; 56 | switch (action) { 57 | case 'open-options': 58 | openOptions(message.data); 59 | return true; 60 | case 'export-file': 61 | exportFile(); 62 | return true; 63 | case 'copy': 64 | copyToClipboard(); 65 | return true; 66 | } 67 | }); 68 | 69 | browser.runtime.onInstalled.addListener(detail => { 70 | const { reason, previousVersion } = detail; 71 | if (reason === 'install') { 72 | // TODO: handle install, maybe compare version 73 | } 74 | 75 | if (reason === 'update') { 76 | if (previousVersion === '0.6.4') { 77 | migration_v_0_6_4(); 78 | } 79 | } 80 | }); 81 | 82 | // NOTE: This is used by the extension to detect updates. 83 | browser.runtime.onConnect.addListener(() => {}); 84 | -------------------------------------------------------------------------------- /packages/extension/src/background/migrations/0.6.4.js: -------------------------------------------------------------------------------- 1 | import { uuid } from 'uuidv4'; 2 | import { generatePageId } from '@yi-note/common/utils'; 3 | import { 4 | storage as StorageService, 5 | file as FileService 6 | } from '@yi-note/common/services'; 7 | 8 | const storage = StorageService.getStorage(); 9 | 10 | const processSyncStorageBookmarks = async data => { 11 | for (const key in data) { 12 | if (typeof data[key] !== 'object' || Array.isArray(data[key])) { 13 | continue; 14 | } 15 | const meta = data[key]; 16 | const url = `https://${key}`; 17 | const id = generatePageId(url); 18 | const existingPage = await storage.getPage(id); 19 | if (!existingPage) { 20 | const page = { 21 | id, 22 | notes: [], 23 | meta: { 24 | title: meta.title, 25 | url 26 | }, 27 | createAt: +new Date() 28 | }; 29 | await storage.addPage(page); 30 | } 31 | } 32 | }; 33 | 34 | const processSyncStorageNotes = async (key, data) => { 35 | const url = `https://${key}`; 36 | const pageId = generatePageId(url); 37 | for (const timestamp in data) { 38 | const note = { 39 | id: uuid(), 40 | content: data[timestamp].content, 41 | timestamp: +timestamp, 42 | type: 'video' 43 | }; 44 | await storage.addNote(pageId, note); 45 | } 46 | }; 47 | 48 | const processLocalStoragePage = async (key, data) => { 49 | const { origin, pathname, search } = new URL(`https://${key}`); 50 | const url = `${origin}${pathname}${search}`; 51 | const pageId = generatePageId(url); 52 | let page = await storage.getPage(pageId); 53 | if (!page) { 54 | const pageObj = { 55 | id: pageId, 56 | notes: [], 57 | meta: { 58 | title: data.title, 59 | url 60 | }, 61 | createAt: +new Date() 62 | }; 63 | page = await storage.addPage(pageObj); 64 | } 65 | 66 | for (const key in data) { 67 | const { comment } = data[key]; 68 | if (comment) { 69 | const note = { 70 | id: uuid(), 71 | content: comment.content, 72 | type: 'article' 73 | }; 74 | await storage.addNote(pageId, note); 75 | } 76 | } 77 | }; 78 | 79 | export default async () => { 80 | // Process data in local storage 81 | const dataInLocal = await browser.storage.local.get(); 82 | FileService.exportJsonFile(dataInLocal, 'storage_local_0.6.4.json', '0.6.4'); 83 | await browser.storage.local.clear(); 84 | for (const key in dataInLocal) { 85 | const value = dataInLocal[key]; 86 | if (typeof value !== 'object' || Array.isArray(value)) { 87 | continue; 88 | } 89 | await processLocalStoragePage(key, value); 90 | } 91 | 92 | // Process data in sync storage 93 | const dataInSync = await browser.storage.sync.get(); 94 | FileService.exportJsonFile(dataInSync, 'storage_sync_0.6.4.json', '0.6.4'); 95 | await browser.storage.sync.clear(); 96 | for (const key in dataInSync) { 97 | if ( 98 | key === 'settings' || 99 | (key.startsWith('vn-') && key !== 'vn-bookmarks') 100 | ) { 101 | continue; 102 | } 103 | 104 | if (key === 'vn-bookmarks') { 105 | await processSyncStorageBookmarks(dataInSync['vn-bookmarks']); 106 | } else { 107 | await processSyncStorageNotes(key, dataInSync[key]); 108 | } 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /packages/extension/src/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import en from '../_locales/en/messages.json'; 5 | 6 | const convertToI18n = resource => { 7 | const convert = (parts, res, value) => { 8 | if (parts.length === 1) { 9 | res[parts[0]] = value; 10 | return; 11 | } 12 | 13 | const part = parts.shift(); 14 | res[part] = res[part] ? res[part] : {}; 15 | convert(parts, res[part], value); 16 | }; 17 | 18 | const res = {}; 19 | for (const key in resource) { 20 | const value = resource[key].message; 21 | const parts = key.split('_'); 22 | convert(parts, res, value); 23 | } 24 | 25 | return res; 26 | }; 27 | 28 | const resources = { 29 | en: convertToI18n(en) 30 | }; 31 | 32 | export const init = () => { 33 | i18n 34 | .use(LanguageDetector) 35 | .use(initReactI18next) 36 | .init({ 37 | resources, 38 | fallbackLng: 'en', 39 | defaultNS: 'common', 40 | interpolation: { 41 | escapeValue: false // react already safes from xss 42 | } 43 | }); 44 | }; 45 | 46 | export default { 47 | init, 48 | i18n 49 | }; 50 | -------------------------------------------------------------------------------- /packages/extension/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as ui } from './ui'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/App/Header/Toolbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | import BookmarksToolbar from '../../Bookmarks/Toolbar'; 4 | import PageToolbar from '../../Page/Toolbar'; 5 | 6 | const Toolbar = () => { 7 | const { pathname } = useLocation(); 8 | let ToolbarComp = null; 9 | if (pathname === '/') { 10 | ToolbarComp = BookmarksToolbar; 11 | } else if (pathname.includes('/pages')) { 12 | ToolbarComp = PageToolbar; 13 | } 14 | 15 | if (!ToolbarComp) { 16 | return null; 17 | } 18 | 19 | return ; 20 | }; 21 | 22 | export default Toolbar; 23 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/App/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import styled from 'styled-components'; 4 | import { 5 | AppBar, 6 | Grid, 7 | Typography, 8 | IconButton, 9 | Hidden, 10 | useMediaQuery 11 | } from '@material-ui/core'; 12 | import { useTheme } from '@material-ui/core/styles'; 13 | import { Menu as MenuIcon } from '@material-ui/icons'; 14 | import Toolbar from './Toolbar'; 15 | import { drawerWidth, headerHeight } from '../constants'; 16 | 17 | const StyledAppBar = styled(AppBar)` 18 | @media (min-width: 600px) { 19 | margin-left: ${drawerWidth}px; 20 | width: calc(100% - ${drawerWidth}px); 21 | } 22 | 23 | > div { 24 | height: ${headerHeight}px; 25 | } 26 | `; 27 | 28 | const StyledTitle = styled(Typography)` 29 | margin-left: ${props => props.theme.spacing(2)}px; 30 | `; 31 | 32 | const StyledMenuButton = styled(IconButton)` 33 | margin-left: 15px; 34 | `; 35 | 36 | const Header = () => { 37 | const { 38 | title, 39 | drawer: { open: drawerOpen } 40 | } = useStoreState(state => state.app); 41 | const { setOpen: setDrawOpen } = useStoreActions( 42 | actions => actions.app.drawer 43 | ); 44 | const theme = useTheme(); 45 | const justify = !useMediaQuery(`(min-width:${theme.breakpoints.values.sm}px)`) 46 | ? 'space-between' 47 | : 'flex-end'; 48 | 49 | const handleDrawerToggle = () => { 50 | setDrawOpen(!drawerOpen); 51 | }; 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | 67 | {title} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default Header; 81 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/App/Snackbar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import { Snackbar as MuiSnackbar } from '@material-ui/core'; 4 | import MuiAlert from '@material-ui/lab/Alert'; 5 | 6 | const Snackbar = () => { 7 | const { 8 | horizontal, 9 | vertical, 10 | message, 11 | open, 12 | severity, 13 | duration 14 | } = useStoreState(state => state.app.snackbar); 15 | const { setStates } = useStoreActions(actions => actions.app.snackbar); 16 | 17 | const handleClose = () => { 18 | setStates({ open: false, message: '' }); 19 | }; 20 | 21 | return ( 22 | 29 | {message} 30 | 31 | ); 32 | }; 33 | 34 | export default Snackbar; 35 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/App/constants.js: -------------------------------------------------------------------------------- 1 | export const drawerWidth = 240; 2 | export const headerHeight = 60; 3 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Switch, Route, useHistory } from 'react-router-dom'; 3 | import { useStoreState, useStoreActions } from 'easy-peasy'; 4 | import styled from 'styled-components'; 5 | import { Container, Grid, LinearProgress } from '@material-ui/core'; 6 | import Header from './Header'; 7 | import Drawer from './Drawer'; 8 | import Snackbar from './Snackbar'; 9 | import Bookmarks from '../Bookmarks'; 10 | import Page from '../Page'; 11 | import Settings from '../Settings'; 12 | import { Alerts } from '@yi-note/common/components'; 13 | import { withTheme } from '@yi-note/common'; 14 | import { headerHeight, drawerWidth } from './constants'; 15 | 16 | const StyledPageContainer = styled(Container)` 17 | margin-top: ${headerHeight + 10}px; 18 | @media (min-width: 600px) { 19 | margin-left: ${drawerWidth}px; 20 | width: calc(100% - ${drawerWidth}px); 21 | } 22 | `; 23 | 24 | const StyledProgress = styled(LinearProgress)` 25 | width: 100%; 26 | `; 27 | 28 | const App = () => { 29 | const history = useHistory(); 30 | const { progress } = useStoreState(state => state.app); 31 | const { 32 | settings: { fetchSettings } 33 | } = useStoreActions(actions => actions); 34 | 35 | useEffect(() => { 36 | browser.runtime.onMessage.addListener(message => { 37 | const { action, data } = message; 38 | switch (action) { 39 | case 'open-page': 40 | history.push(`/pages/${data}`); 41 | return true; 42 | case 'filter-by-tags': 43 | history.push(`/?tags=${data.join(',')}`); 44 | return true; 45 | } 46 | }); 47 | }, [history]); 48 | 49 | useEffect(() => { 50 | fetchSettings(); 51 | }, [fetchSettings]); 52 | 53 | return ( 54 | <> 55 |
56 | 57 | 58 | 59 | {progress && ( 60 | 61 | 62 | 63 | )} 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default withTheme(App); 80 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/Bookmarks/NoBookmark/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | const NoBookmark = () => { 5 | const { t } = useTranslation('bookmark'); 6 | 7 | return
{t('nobookmark')}
; 8 | }; 9 | 10 | export default NoBookmark; 11 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/Bookmarks/Toolbar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Grid, IconButton, Tooltip } from '@material-ui/core'; 5 | import { 6 | GetApp as ExportIcon, 7 | Check as CheckIcon, 8 | Close as CloseIcon, 9 | FilterList as FilterIcon 10 | } from '@material-ui/icons'; 11 | import { 12 | storage as StorageService, 13 | file as FileService 14 | } from '@yi-note/common/services'; 15 | import Markdown from '@yi-note/common/services/markdown'; 16 | 17 | const Toolbar = () => { 18 | const { t } = useTranslation('options'); 19 | const { 20 | toolbar: { exporting, filtering, exportFormat }, 21 | bookmarks 22 | } = useStoreState(state => state.bookmarks); 23 | const { 24 | toolbar: { setExporting, setFiltering }, 25 | unSelectTags 26 | } = useStoreActions(actions => actions.bookmarks); 27 | 28 | const startExport = () => { 29 | setExporting(true); 30 | }; 31 | 32 | const executeExport = () => { 33 | const ids = bookmarks.reduce((acc, bookmark) => { 34 | if (bookmark.selected) { 35 | acc.push(bookmark.id); 36 | } 37 | return acc; 38 | }, []); 39 | return StorageService.getStorage() 40 | .getPagesForExport(ids) 41 | .then(pages => { 42 | if (exportFormat === 'markdown') { 43 | const data = Markdown.pagesToMarkdown(pages); 44 | return FileService.exportMarkdownFile(data, 'yi-note.md'); 45 | } 46 | return FileService.exportJsonFile(pages, 'yi-note.json'); 47 | }) 48 | .then(() => setExporting(false)); 49 | }; 50 | 51 | const cancelExport = () => { 52 | setExporting(false); 53 | }; 54 | 55 | const toggleTags = () => { 56 | if (filtering) { 57 | // Clear selected tags if cancel filter 58 | unSelectTags(); 59 | } 60 | setFiltering(!filtering); 61 | }; 62 | 63 | return ( 64 | 65 | {exporting ? ( 66 | <> 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ) : ( 75 | <> 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | 89 | ); 90 | }; 91 | 92 | export default Toolbar; 93 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/Page/VideoNoteItem/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Grid } from '@material-ui/core'; 3 | 4 | export const StyledImgContainer = styled.div` 5 | position: relative; 6 | `; 7 | 8 | export const StyledImg = styled.img` 9 | @media (max-width: 960px) { 10 | width: 426px; 11 | height: 240px; 12 | } 13 | 14 | width: 640px; 15 | height: 360px; 16 | `; 17 | 18 | export const StyledNoteContainer = styled(Grid)` 19 | @media (max-width: 960px) { 20 | width: 426px; 21 | } 22 | 23 | @media (min-width: 961px) and (max-width: 1280px) { 24 | width: 640px !important; 25 | } 26 | `; 27 | 28 | export const StyledEditorContainer = styled(Grid)` 29 | flex: 1; 30 | border: solid 1px #e1e4e8; 31 | border-radius: 5px; 32 | `; 33 | 34 | export const StyledTextArea = styled(Grid)` 35 | flex: 1; 36 | 37 | & textarea { 38 | font-size: 14px; 39 | padding: 0 10px; 40 | flex: 1; 41 | min-height: 150px; 42 | border: none; 43 | outline: none; 44 | } 45 | 46 | & div.markdown-body { 47 | font-size: 14px; 48 | padding: 0 10px; 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/Page/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { useStoreState, useStoreActions } from 'easy-peasy'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { Grid, Typography, Chip } from '@material-ui/core'; 6 | import VideoNoteItem from './VideoNoteItem'; 7 | import { TagDialog } from '@yi-note/common/components'; 8 | import { TYPE_VIDEO_NOTE } from '@yi-note/common/constants'; 9 | 10 | const Page = () => { 11 | const { t } = useTranslation('options'); 12 | const { id } = useParams(); 13 | const { 14 | data: { 15 | meta: { title, url }, 16 | notes, 17 | tags 18 | } 19 | } = useStoreState(state => state.page); 20 | const { 21 | app: { setTitle: setAppTitle }, 22 | page: { fetchPage, addTag, removeTag } 23 | } = useStoreActions(actions => actions); 24 | 25 | useEffect(() => { 26 | setAppTitle(t('page.title')); 27 | fetchPage(id); 28 | }, [fetchPage, id, setAppTitle, t]); 29 | 30 | return ( 31 | <> 32 | 33 | 34 | {title} 35 | 36 | 37 | {tags.map(tag => ( 38 | 39 | 40 | 41 | ))} 42 | 43 | 44 | {notes.map(note => ( 45 | 46 | {note.type === TYPE_VIDEO_NOTE ? ( 47 | 48 | ) : ( 49 |
{note.content}
50 | )} 51 |
52 | ))} 53 |
54 |
55 | 56 | 57 | ); 58 | }; 59 | 60 | export default Page; 61 | -------------------------------------------------------------------------------- /packages/extension/src/options/containers/Settings/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useStoreActions } from 'easy-peasy'; 4 | import { Grid } from '@material-ui/core'; 5 | import Video from './Video'; 6 | import ExportAndImport from './ExportAndImport'; 7 | 8 | const Settings = () => { 9 | const { t } = useTranslation('options'); 10 | const { 11 | app: { setTitle } 12 | } = useStoreActions(actions => actions); 13 | 14 | useEffect(() => { 15 | setTitle(t('settings.title')); 16 | }, [setTitle, t]); 17 | 18 | return ( 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Settings; 31 | -------------------------------------------------------------------------------- /packages/extension/src/options/globalStyle.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import { APP_ID } from '@yi-note/common/constants'; 3 | 4 | export default createGlobalStyle` 5 | html, body { 6 | height: 100%; 7 | } 8 | 9 | .${APP_ID} { 10 | font-size: 16px; 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /packages/extension/src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | YiNote management page 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/extension/src/options/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { HashRouter as Router } from 'react-router-dom'; 4 | import { StoreProvider, createStore } from 'easy-peasy'; 5 | import Logger from 'js-logger'; 6 | import PDFGenerator from '@yi-note/common/services/pdf'; 7 | import { APP_ID } from '@yi-note/common/constants'; 8 | import { storeModel } from './store'; 9 | import App from './containers/App'; 10 | import GlobalStyle from './globalStyle'; 11 | import i18n from '../i18n'; 12 | 13 | i18n.init(); 14 | Logger.useDefaults(); 15 | 16 | const store = createStore(storeModel); 17 | 18 | PDFGenerator.init(); 19 | 20 | const container = document.createElement('div'); 21 | container.id = APP_ID; 22 | container.classList.add(APP_ID); 23 | document.body.appendChild(container); 24 | 25 | ReactDOM.render( 26 | 27 | 28 | 29 | 30 | 31 | , 32 | container 33 | ); 34 | -------------------------------------------------------------------------------- /packages/extension/src/options/services/importData.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT: update import logic if storage or data structure changed 2 | 3 | import { storage as StorageService } from '@yi-note/common/services'; 4 | 5 | export default async pages => { 6 | const storage = StorageService.getStorage(); 7 | 8 | if (!Array.isArray(pages)) { 9 | throw new Error('Incorrect data formart: pages should be an array'); 10 | } 11 | 12 | for (const page of pages) { 13 | const { id, notes } = page; 14 | const existingPage = await storage.getPage(id); 15 | let pageToSave; 16 | if (existingPage) { 17 | existingPage.notes = [...existingPage.notes, ...notes]; 18 | pageToSave = existingPage; 19 | } else { 20 | pageToSave = page; 21 | } 22 | await storage.addPage(pageToSave); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/extension/src/options/store/app.js: -------------------------------------------------------------------------------- 1 | import { action } from 'easy-peasy'; 2 | 3 | const appModel = { 4 | title: '', 5 | setTitle: action((state, payload) => { 6 | state.title = payload; 7 | }), 8 | drawer: { 9 | open: false, 10 | setOpen: action((state, payload) => { 11 | state.open = payload; 12 | }) 13 | }, 14 | reset: action(state => { 15 | state.title = ''; 16 | }), 17 | progress: false, 18 | setProgress: action((state, payload) => { 19 | state.progress = payload; 20 | }), 21 | snackbar: { 22 | horizontal: 'center', 23 | vertical: 'bottom', 24 | message: '', 25 | open: false, 26 | severity: 'info', 27 | duration: 5000, 28 | setStates: action((state, payload) => { 29 | const { 30 | horizontal, 31 | vertical, 32 | message, 33 | open, 34 | severity, 35 | duration 36 | } = payload; 37 | state.open = open; 38 | if (horizontal) { 39 | state.horizontal = horizontal; 40 | } 41 | if (vertical) { 42 | state.vertical = vertical; 43 | } 44 | if (message) { 45 | state.message = message; 46 | } 47 | if (severity) { 48 | state.severity = severity; 49 | } 50 | if (duration) { 51 | state.duration = duration; 52 | } 53 | }) 54 | } 55 | }; 56 | 57 | export default appModel; 58 | -------------------------------------------------------------------------------- /packages/extension/src/options/store/bookmarks.js: -------------------------------------------------------------------------------- 1 | import { action, thunk, thunkOn } from 'easy-peasy'; 2 | import { storage as StorageService } from '@yi-note/common/services'; 3 | 4 | const storage = StorageService.getStorage(); 5 | 6 | export default { 7 | bookmarks: [], 8 | tags: [], 9 | setBookmarks: action((state, payload) => { 10 | state.bookmarks = [ 11 | ...payload.sort((b1, b2) => b1.createdAt - b2.createdAt) 12 | ]; 13 | }), 14 | setBookmark: action((state, payload) => { 15 | const { id } = payload; 16 | state.bookmarks = state.bookmarks.map(bookmark => { 17 | if (bookmark.id === id) { 18 | return { ...bookmark, ...payload }; 19 | } 20 | return bookmark; 21 | }); 22 | }), 23 | setTags: action((state, payload) => { 24 | state.tags = [...payload]; 25 | }), 26 | selectTag: action((state, payload) => { 27 | state.tags = state.tags.map(tag => { 28 | if (tag.tag === payload) { 29 | tag.selected = !tag.selected; 30 | return tag; 31 | } 32 | return tag; 33 | }); 34 | }), 35 | unSelectTags: action(state => { 36 | state.tags = state.tags.map(tag => { 37 | tag.selected = false; 38 | return tag; 39 | }); 40 | }), 41 | fetchBookmarks: thunk(async actions => { 42 | const bookmarks = await storage.getBookmarks(); 43 | actions.setBookmarks(bookmarks); 44 | }), 45 | removeBookmark: thunk(async (actions, pageId, { getState }) => { 46 | await storage.removePage(pageId); 47 | const { bookmarks } = getState(); 48 | actions.setBookmarks(bookmarks.filter(bookmark => bookmark.id !== pageId)); 49 | }), 50 | fetchTags: thunk(async (actions, tagsFromUrl) => { 51 | let tags = await storage.getTags(); 52 | tags = tags.map(tag => { 53 | if (tagsFromUrl.includes(tag)) { 54 | return { tag, selected: true }; 55 | } 56 | return { tag, selected: false }; 57 | }); 58 | actions.setTags(tags); 59 | }), 60 | toolbar: { 61 | exporting: false, 62 | setExporting: action((state, payload) => { 63 | state.exporting = payload; 64 | }), 65 | filtering: true, 66 | setFiltering: action((state, payload) => { 67 | state.filtering = payload; 68 | }), 69 | exportFormat: 'json', 70 | setExportFormat: action((state, payload) => { 71 | state.exportFormat = payload; 72 | }) 73 | }, 74 | reset: action(state => { 75 | state.bookmarks = []; 76 | }), 77 | onTagsChange: thunkOn( 78 | actions => [actions.setTags, actions.selectTag, actions.unSelectTags], 79 | async (actions, _, { getState }) => { 80 | const { tags = [] } = getState(); 81 | const selectedTags = tags 82 | .filter(({ selected }) => !!selected) 83 | .map(({ tag }) => tag); 84 | const bookmarks = await storage.filterBookmarksByTags(selectedTags); 85 | actions.setBookmarks(bookmarks); 86 | } 87 | ) 88 | }; 89 | -------------------------------------------------------------------------------- /packages/extension/src/options/store/index.js: -------------------------------------------------------------------------------- 1 | export { default as storeModel } from './store'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/options/store/store.js: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import bookmarks from './bookmarks'; 3 | import { page, settings, alerts, tagDialog } from '@yi-note/common/store'; 4 | import { thunk } from 'easy-peasy'; 5 | 6 | const storeModel = { 7 | app, 8 | bookmarks, 9 | page, 10 | settings, 11 | alerts, 12 | tagDialog, 13 | reset: thunk(actions => { 14 | for (const model in actions) { 15 | if (typeof actions[model].reset === 'undefined') { 16 | continue; 17 | } 18 | actions[model].reset(); 19 | } 20 | }) 21 | }; 22 | 23 | export default storeModel; 24 | -------------------------------------------------------------------------------- /packages/extension/src/ui/components/ScrollableList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import Grid from '@material-ui/core/Grid'; 5 | 6 | const StyledContainer = styled(Grid)` 7 | height: ${props => props.height}px; 8 | max-height: 100%; 9 | overflow-y: scroll; 10 | `; 11 | 12 | const ScrollableList = ({ items, renderItem, height }) => { 13 | return ( 14 | 15 | {items.map(item => ( 16 | 17 | {renderItem(item)} 18 | 19 | ))} 20 | 21 | ); 22 | }; 23 | 24 | ScrollableList.propTypes = { 25 | items: PropTypes.array.isRequired, 26 | renderItem: PropTypes.func.isRequired, 27 | height: PropTypes.number 28 | }; 29 | 30 | export default ScrollableList; 31 | -------------------------------------------------------------------------------- /packages/extension/src/ui/components/Spinner/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Grid, CircularProgress } from '@material-ui/core'; 4 | 5 | const StyledContainer = styled(Grid)` 6 | padding: 20px; 7 | & svg { 8 | width: 40px; 9 | height: 40px; 10 | } 11 | `; 12 | 13 | const Spinner = () => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Spinner; 24 | -------------------------------------------------------------------------------- /packages/extension/src/ui/components/TextButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | const StyledButton = styled.button` 6 | font-weight: 500; 7 | font-size: 0.9em; 8 | cursor: pointer; 9 | padding: 0; 10 | border: none; 11 | background: none; 12 | color: ${props => props.color}; 13 | `; 14 | 15 | const TextButton = ({ children, color, onClick }) => { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | TextButton.propTypes = { 24 | children: PropTypes.string.isRequired, 25 | onClick: PropTypes.func.isRequired, 26 | color: PropTypes.string 27 | }; 28 | 29 | TextButton.defaultProps = { 30 | color: '#4763ff' 31 | }; 32 | 33 | export default TextButton; 34 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/Footer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useTranslation } from 'react-i18next'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import { 6 | GitHub as GitHubIcon, 7 | Settings as SettingsIcon 8 | } from '@material-ui/icons'; 9 | import { IconButton } from '@yi-note/common/components'; 10 | import { GITHUB_URL } from '@yi-note/common/constants'; 11 | 12 | const StyledContainer = styled(Grid)` 13 | padding: 10px 5px; 14 | background: #fafafa; 15 | `; 16 | 17 | const Footer = () => { 18 | const { t } = useTranslation('footer'); 19 | 20 | const openGithubRepo = () => { 21 | window.open(GITHUB_URL, '_blank'); 22 | }; 23 | 24 | const openManagementPage = () => { 25 | browser.runtime.sendMessage({ action: 'open-options' }); 26 | }; 27 | 28 | return ( 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default Footer; 53 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreActions } from 'easy-peasy'; 3 | import { useHistory, useLocation } from 'react-router-dom'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import { 6 | ChevronRight as ChevronRightIcon, 7 | Close as CloseIcon 8 | } from '@material-ui/icons'; 9 | import { IconButton } from '@yi-note/common/components'; 10 | import { StyledContainer } from './styled'; 11 | import Search from '../Search'; 12 | 13 | const Header = () => { 14 | const history = useHistory(); 15 | const { pathname } = useLocation(); 16 | const { 17 | search: { setQuery }, 18 | app: { setOpen: setAppOpen } 19 | } = useStoreActions(actions => actions); 20 | 21 | const handleCloseSearch = () => { 22 | if (history.length > 1) { 23 | history.goBack(); 24 | } else { 25 | setAppOpen(false); 26 | } 27 | setQuery(''); 28 | }; 29 | 30 | const handleClose = () => setAppOpen(false); 31 | 32 | return ( 33 | 40 | 41 | 42 | 43 | 44 | {pathname === '/search' && history.length > 1 ? ( 45 | 46 | 47 | 48 | ) : ( 49 | 50 | 51 | 52 | )} 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default Header; 59 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/Header/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Grid from '@material-ui/core/Grid'; 3 | 4 | export const StyledContainer = styled(Grid)` 5 | padding: 10px 5px 5px; 6 | background-color: #f9f9f9; 7 | cursor: pointer; 8 | border-bottom: 2px solid #cccccc; 9 | `; 10 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/Search/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory, useLocation } from 'react-router-dom'; 3 | import { useStoreState, useStoreActions } from 'easy-peasy'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { debounce } from 'throttle-debounce'; 6 | import { SearchSharp as SearchIcon } from '@material-ui/icons'; 7 | import Grid from '@material-ui/core/Grid'; 8 | import { StyledArrow, StyledInput } from './styled'; 9 | 10 | const Search = () => { 11 | const { t } = useTranslation('search'); 12 | const history = useHistory(); 13 | const { pathname } = useLocation(); 14 | const { query, type } = useStoreState(state => state.search); 15 | const { search, setQuery } = useStoreActions(actions => actions.search); 16 | 17 | const debouncedSearch = debounce(500, search); 18 | 19 | const handleFocus = () => { 20 | if (pathname !== '/search') { 21 | history.push('/search'); 22 | } 23 | }; 24 | 25 | const onInputChangeHandler = e => { 26 | const { value } = e.target; 27 | setQuery(value); 28 | 29 | if (value.length >= 2) { 30 | debouncedSearch(query, type); 31 | } 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 46 | 47 | ); 48 | }; 49 | 50 | export default Search; 51 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/Search/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Grid from '@material-ui/core/Grid'; 3 | 4 | export const StyledArrow = styled(Grid)` 5 | width: 24px; 6 | height: 24px; 7 | `; 8 | 9 | export const StyledInput = styled.input` 10 | border: none; 11 | outline: none; 12 | flex: 1; 13 | font-size: 1em; 14 | background-color: #f9f9f9; 15 | `; 16 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/App/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledDrawer = styled.div` 4 | display: flex; 5 | position: fixed; 6 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 7 | width: ${props => props.theme.panel.width}px; 8 | top: 0; 9 | bottom: 0; 10 | right: 0; 11 | background: #ffffff; 12 | color: #000000; 13 | overflow: hidden; 14 | z-index: ${props => props.theme.panel.zIndex}; 15 | transition: transform 0.3s; 16 | transform: ${props => (props.open ? 'translateX(0)' : 'translateX(100%)')}; 17 | 18 | ::-webkit-scrollbar { 19 | width: 0; 20 | } 21 | `; 22 | 23 | export const StyledViewWrapper = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | padding: 10px 5px; 27 | width: -webkit-fill-available; 28 | width: -moz-fill-available; 29 | word-break: break-all; 30 | height: calc( 31 | 100% - ${props => props.theme.header.height}px - 32 | ${props => props.theme.footer.height}px 33 | ); 34 | 35 | & > div { 36 | margin-bottom: 8px; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/ReloadView/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { Grid } from '@material-ui/core'; 4 | import TextButton from '../../components/TextButton'; 5 | 6 | const ReloadView = () => { 7 | const { t } = useTranslation('reloadView'); 8 | 9 | const reload = () => window.location.reload(); 10 | 11 | return ( 12 | 13 | {t('reload')} 14 | 15 | ); 16 | }; 17 | 18 | export default ReloadView; 19 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/SearchView/BookmarkItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Highlight from 'react-highlighter'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import { StyledArchor, StyledImg, StyledMainLine } from '../styled'; 6 | 7 | const BookmarkItem = ({ item: { title, icon, url }, query }) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | {icon && } 14 | {title} 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | BookmarkItem.propTypes = { 23 | item: PropTypes.shape({ 24 | title: PropTypes.string.isRequired, 25 | url: PropTypes.string.isRequired, 26 | icon: PropTypes.string, 27 | description: PropTypes.string 28 | }), 29 | query: PropTypes.string 30 | }; 31 | 32 | export default BookmarkItem; 33 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/SearchView/NoteItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Highlight from 'react-highlighter'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import { secondsToTime, buildAutoSeekUrl } from '@yi-note/common/utils'; 6 | import Markdown from '@yi-note/common/services/markdown'; 7 | import { StyledTimestamp } from './styled'; 8 | import { 9 | StyledImg, 10 | StyledArchor, 11 | StyledMainLine, 12 | StyledAdditionalInfo 13 | } from '../styled'; 14 | 15 | const NoteItem = ({ 16 | item: { content, timestamp, page: { title, url, icon } = {} }, 17 | query 18 | }) => { 19 | return ( 20 | 25 | 26 | 27 | 28 | {secondsToTime(timestamp)} 29 | {Markdown.toText(content)} 30 | 31 | 32 | 33 | 34 | {icon && } 35 | {title} 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | NoteItem.propTypes = { 44 | item: PropTypes.shape({ 45 | content: PropTypes.string.isRequired, 46 | timestamp: PropTypes.number.isRequired, 47 | page: PropTypes.shape({ 48 | title: PropTypes.string.isRequired, 49 | url: PropTypes.string.isRequired, 50 | icon: PropTypes.string 51 | }) 52 | }), 53 | query: PropTypes.string 54 | }; 55 | 56 | export default NoteItem; 57 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/SearchView/NoteItem/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledTimestamp = styled.span` 4 | font-weight: bold; 5 | margin-right: 8px; 6 | `; 7 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/SearchView/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import { useStoreState, useStoreActions } from 'easy-peasy'; 4 | import { useTranslation } from 'react-i18next'; 5 | import Tabs from '@material-ui/core/Tabs'; 6 | import Tab from '@material-ui/core/Tab'; 7 | import NoteItem from './NoteItem'; 8 | import BookmarkItem from './BookmarkItem'; 9 | import ScrollableList from '../../components/ScrollableList'; 10 | import { TYPE_BOOKMARKS, TYPE_NOTES } from '@yi-note/common/constants'; 11 | 12 | const StyledItemWrapper = styled.div` 13 | background: #f6f6f6; 14 | padding: 3px; 15 | `; 16 | 17 | const SearchView = () => { 18 | const { t } = useTranslation('searchView'); 19 | const { query, type, results, bookmarks, notes } = useStoreState( 20 | state => state.search 21 | ); 22 | const { 23 | setQuery, 24 | setType, 25 | setResults, 26 | fetchBookmarks, 27 | fetchNotes 28 | } = useStoreActions(actions => actions.search); 29 | 30 | useEffect(() => { 31 | if (type === TYPE_BOOKMARKS) { 32 | if (bookmarks.length) { 33 | setResults(bookmarks); 34 | } else { 35 | fetchBookmarks(); 36 | } 37 | } else if (type === TYPE_NOTES) { 38 | if (notes.length) { 39 | setResults(notes); 40 | } else { 41 | fetchNotes(); 42 | } 43 | } 44 | }, [type, fetchBookmarks, fetchNotes, setResults, bookmarks, notes]); 45 | 46 | const handleTypeChange = (event, newValue) => { 47 | setType(newValue); 48 | setQuery(''); 49 | }; 50 | 51 | const ItemComponent = { 52 | [TYPE_BOOKMARKS]: BookmarkItem, 53 | [TYPE_NOTES]: NoteItem 54 | }[type]; 55 | 56 | return ( 57 | <> 58 | 59 | 60 | 61 | 62 | ( 65 | 66 | 67 | 68 | )} 69 | /> 70 | 71 | ); 72 | }; 73 | 74 | export default SearchView; 75 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/SearchView/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledArchor = styled.a` 4 | text-decoration: none; 5 | color: #000; 6 | 7 | &:hover { 8 | text-decoration: none; 9 | cursor: pointer; 10 | } 11 | `; 12 | 13 | export const StyledImg = styled.img` 14 | float: left; 15 | width: 20px; 16 | height: 20px; 17 | margin-right: 4px; 18 | `; 19 | 20 | export const StyledMainLine = styled.div` 21 | font-size: 0.8em; 22 | & * { 23 | font-size: inherit; 24 | } 25 | `; 26 | 27 | export const StyledAdditionalInfo = styled.div` 28 | width: 100%; 29 | font-size: 0.8em; 30 | color: grey; 31 | & > * { 32 | font-size: inherit; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/NoteItem/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import { ChevronRight as ExpandIcon } from '@material-ui/icons'; 4 | 5 | export const StyledMainRow = styled(Grid)` 6 | background: #f6f6f6; 7 | height: 36px; 8 | `; 9 | 10 | export const StyledExpandedSection = styled(Grid)` 11 | padding: ${props => (props.expanded ? 4 : 0)}px; 12 | border-top: ${props => props.expanded && '1px solid #d3d3d3'}; 13 | max-height: ${props => (props.expanded ? 400 : 0)}px; 14 | overflow: hidden; 15 | overflow-y: scroll; 16 | transition: max-height 0.2s ease-in-out; 17 | `; 18 | 19 | export const StyledExpandMoreIcon = styled(ExpandIcon)` 20 | transform: ${({ expanded }) => expanded && 'rotate(90deg)'}; 21 | transition: all 0.1s ease-in-out; 22 | `; 23 | 24 | export const StyledSummary = styled.div` 25 | max-width: 200px; 26 | overflow: hidden; 27 | text-overflow: ellipsis; 28 | white-space: nowrap; 29 | `; 30 | 31 | export const StyledNote = styled.div` 32 | width: 360px; 33 | `; 34 | 35 | export const StyledTimestamp = styled.div` 36 | font-weight: 500; 37 | padding: 0 2px; 38 | `; 39 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/copylink-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/copylink-48.png -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/facebook-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/facebook-48.png -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/index.js: -------------------------------------------------------------------------------- 1 | export { default as facebookImg } from './facebook-48.png'; 2 | export { default as twitterImg } from './twitter-48.png'; 3 | export { default as copylinkImg } from './copylink-48.png'; 4 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/twitter-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/icons/twitter-48.png -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Share/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Grid, IconButton } from '@material-ui/core'; 5 | import { getFileUrl } from '@yi-note/common/utils'; 6 | import { INSTALLATION_URL } from '@yi-note/common/constants'; 7 | import { StyledTitle } from '../styled'; 8 | import * as icons from './icons'; 9 | 10 | const StyledImg = styled.img` 11 | width: 30px; 12 | height: 30px; 13 | `; 14 | 15 | const StyledStatus = styled.span` 16 | color: #4763ff; 17 | `; 18 | 19 | const methods = ['facebook', 'twitter', 'copylink']; 20 | const hashTags = [ 21 | 'edtech', 22 | 'YiNote', 23 | 'Turbonote', 24 | 'mooc', 25 | 'flipclass', 26 | 'video', 27 | 'youtube', 28 | 'notetaking' 29 | ]; 30 | 31 | const Share = () => { 32 | const { t } = useTranslation('share'); 33 | const [copied, setCopied] = useState(false); 34 | 35 | const openShareDialog = url => { 36 | const width = 670; 37 | var height = 340; 38 | var spec = `width=670, height=340, top=${(window.innerHeight - height) / 39 | 2}, left=${(window.innerWidth - width) / 2}`; 40 | window.open(url, '', spec); 41 | }; 42 | 43 | const shareToFacebook = () => { 44 | const url = encodeURIComponent(INSTALLATION_URL); 45 | const text = encodeURIComponent(t('text')); 46 | const tags = hashTags.map(ht => `#${ht}`).join(''); 47 | openShareDialog( 48 | `https://www.facebook.com/sharer/sharer.php?u=${url}&hashtag=${encodeURIComponent( 49 | tags 50 | )}"e=${text}` 51 | ); 52 | }; 53 | 54 | const shareToTwitter = () => { 55 | const text = encodeURIComponent(t('text')); 56 | const url = encodeURIComponent(INSTALLATION_URL); 57 | const tags = hashTags.join(','); 58 | openShareDialog( 59 | `https://twitter.com/share?url=${url}&text=${text}&hashtags=${tags}` 60 | ); 61 | }; 62 | 63 | const copylink = () => { 64 | browser.runtime 65 | .sendMessage({ 66 | action: `copy`, 67 | data: INSTALLATION_URL 68 | }) 69 | .then(() => { 70 | setCopied(true); 71 | window.setTimeout(() => setCopied(false), 3 * 1000); 72 | }); 73 | }; 74 | 75 | const handleClick = method => () => { 76 | switch (method) { 77 | case 'facebook': 78 | shareToFacebook(); 79 | return; 80 | case 'twitter': 81 | shareToTwitter(); 82 | return; 83 | case 'copylink': 84 | copylink(); 85 | return; 86 | } 87 | }; 88 | 89 | return ( 90 | 91 | 92 | 93 | {t('title')} 94 | 95 | {copied && ( 96 | 97 | {t('copylink.success')} 98 | 99 | )} 100 | 101 | 102 | {methods.map(method => ( 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | ))} 111 | 112 | 113 | ); 114 | }; 115 | 116 | export default Share; 117 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Sponsor/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { Grid, Link } from '@material-ui/core'; 4 | import { GitHub as GitHubIcon } from '@material-ui/icons'; 5 | import { PatreonIcon, PaypalIcon } from '@yi-note/common/icons'; 6 | import { StyledTitle } from '../styled'; 7 | 8 | const SPONSOR_GITHUB_URL = 'https://github.com/sponsors/shuowu'; 9 | const SPONSOR_PATREON_URL = 'https://www.patreon.com/yinote'; 10 | const SPONSOR_PAYPAL_URL = 'https://paypal.me/turbonote'; 11 | 12 | const Sponsor = () => { 13 | const { t } = useTranslation('sponsor'); 14 | 15 | return ( 16 | 17 | 18 | {t('title')} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {t('github')} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {t('patreon')} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {t('paypal')} 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default Sponsor; 57 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/Star/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { Grid, Link } from '@material-ui/core'; 4 | import { GitHub as GitHubIcon } from '@material-ui/icons'; 5 | import { StyledTitle } from '../styled'; 6 | import { GITHUB_URL } from '@yi-note/common/constants'; 7 | 8 | const Star = () => { 9 | const { t } = useTranslation('star'); 10 | return ( 11 | 12 | 13 | 14 | {t('title')} 15 | 16 | 17 |
{t('description')}
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | {t('github')} 27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default Star; 35 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Grid, Backdrop, Fade, Typography } from '@material-ui/core'; 5 | import Alert from '@material-ui/lab/Alert'; 6 | import { StyledModal, StyledPaper } from './styled'; 7 | import Sponsor from './Sponsor'; 8 | import Star from './Star'; 9 | import Share from './Share'; 10 | import { APP_ID } from '@yi-note/common/constants'; 11 | 12 | const SupportExtension = () => { 13 | const { t } = useTranslation('notesView'); 14 | const { open } = useStoreState(state => state.videoNotes.support); 15 | const { setOpen } = useStoreActions(actions => actions.videoNotes.support); 16 | const containerRef = useRef(null); 17 | const [message, setMessage] = useState({}); 18 | 19 | useEffect(() => { 20 | containerRef.current = document.getElementById(APP_ID); 21 | }, []); 22 | 23 | const clearState = () => { 24 | setMessage({}); 25 | }; 26 | 27 | const handleClose = () => { 28 | clearState(); 29 | setOpen(false); 30 | }; 31 | 32 | return ( 33 | 43 | 44 | 45 | 46 | {message.status && ( 47 | 48 | {message.message} 49 | 50 | )} 51 | 52 | {t('support.text')} 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export default SupportExtension; 71 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/SupportExtension/styled.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Modal } from '@material-ui/core'; 3 | 4 | export const StyledModal = styled(Modal)` 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | `; 9 | 10 | export const StyledPaper = styled.div` 11 | background-color: ${props => props.theme.palette.background.paper}; 12 | box-shadow: ${props => props.theme.shadows[2]}; 13 | padding: ${props => props.theme.spacing(2, 4, 3)}; 14 | min-height: 200px; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | outline: none; 19 | width: 350px; 20 | `; 21 | 22 | export const StyledTitle = styled.div` 23 | font-size: 1.2em; 24 | font-weight: 500; 25 | `; 26 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/Tags/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useStoreState } from 'easy-peasy'; 3 | import { Grid, Chip } from '@material-ui/core'; 4 | 5 | const Tags = () => { 6 | const { tags } = useStoreState(state => state.page.data); 7 | 8 | const handleTagClick = tag => { 9 | browser.runtime.sendMessage({ 10 | action: 'open-options', 11 | data: { action: 'filter-by-tags', data: [tag] } 12 | }); 13 | }; 14 | 15 | return ( 16 | 17 | {tags.map(tag => ( 18 | 19 | 25 | 26 | ))} 27 | 28 | ); 29 | }; 30 | 31 | export default Tags; 32 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/Toolbar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTranslation } from 'react-i18next'; 3 | import styled from 'styled-components'; 4 | import { useStoreState, useStoreActions } from 'easy-peasy'; 5 | import { Grid } from '@material-ui/core'; 6 | import { 7 | BookmarkBorderOutlined as BookmarkIcon, 8 | Sync as SyncIcon, 9 | OpenInNew as OpenInNewIcon, 10 | FavoriteBorder as SponsorIcon 11 | } from '@material-ui/icons'; 12 | import { IconButton } from '@yi-note/common/components'; 13 | import { useSyncNotes, useLoadScreenshots } from '../../../hooks'; 14 | 15 | export const StyledIconContainer = styled(Grid)` 16 | padding-right: 5px; 17 | `; 18 | 19 | const Toolbar = () => { 20 | const { t } = useTranslation(['notesView', 'bookmark']); 21 | const { id } = useStoreState(state => state.page.data); 22 | const { 23 | videoNotes: { 24 | support: { setOpen: setSupportExtensionOpen } 25 | }, 26 | page: { bookmarkPage, removePage }, 27 | alerts: { show: showAlerts } 28 | } = useStoreActions(actions => actions); 29 | const { platform, hasNotesToSync, getNotesToSync } = useSyncNotes(); 30 | const { loadScreenshots } = useLoadScreenshots(); 31 | 32 | const handleRemovePage = () => { 33 | showAlerts({ 34 | content: t('bookmark:remove.alert'), 35 | onConfirm: removePage.bind(null, id) 36 | }); 37 | }; 38 | 39 | const handleOpenInDetailPage = () => { 40 | browser.runtime.sendMessage({ 41 | action: 'open-options', 42 | data: { action: 'open-page', data: id } 43 | }); 44 | }; 45 | 46 | const handleOpenSupportDialog = () => { 47 | setSupportExtensionOpen(true); 48 | }; 49 | 50 | const handleSyncNotes = async () => { 51 | const notesToSync = await getNotesToSync(); 52 | loadScreenshots(notesToSync); 53 | }; 54 | 55 | return ( 56 | 57 | {!id ? ( 58 | 59 | 63 | 64 | 65 | 66 | ) : ( 67 | 68 | 73 | 74 | 75 | 76 | )} 77 | {hasNotesToSync && ( 78 | 79 | 83 | 84 | 85 | 86 | )} 87 | {id && ( 88 | 89 | 93 | 94 | 95 | 96 | )} 97 | 98 | 103 | 104 | 105 | 106 | 107 | ); 108 | }; 109 | 110 | export default Toolbar; 111 | -------------------------------------------------------------------------------- /packages/extension/src/ui/containers/VideoNotesView/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { useStoreState, useStoreActions } from 'easy-peasy'; 3 | import styled from 'styled-components'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { Grid } from '@material-ui/core'; 6 | import { generatePageId } from '@yi-note/common/utils'; 7 | import SupportExtension from './SupportExtension'; 8 | import NoteItem from './NoteItem'; 9 | import Editor from './Editor'; 10 | import Toolbar from './Toolbar'; 11 | import Tags from './Tags'; 12 | import ScrollableList from '../../components/ScrollableList'; 13 | import Spinner from '../../components/Spinner'; 14 | import { useLoadScreenshots } from '../../hooks'; 15 | 16 | export const StyledEditorContainer = styled.div` 17 | height: 70px; 18 | `; 19 | 20 | export const StyledTitle = styled.div` 21 | font-weight: 500; 22 | `; 23 | 24 | const NotesView = () => { 25 | const { t } = useTranslation(['notesView', 'bookmark']); 26 | const { 27 | page: { 28 | data: { id, notes, meta, tags } 29 | }, 30 | app: { url } 31 | } = useStoreState(state => state); 32 | const { 33 | page: { fetchPage, bookmarkPage } 34 | } = useStoreActions(actions => actions); 35 | const tryLoadMeta = useRef(false); 36 | const { loading } = useLoadScreenshots(); 37 | 38 | useEffect(() => { 39 | if (!id) { 40 | const pageId = generatePageId(url); 41 | fetchPage(pageId); 42 | } else if ( 43 | (!meta || !meta.description || !meta.image) && 44 | !tryLoadMeta.current 45 | ) { 46 | // Try add more meta info for migrated data 47 | tryLoadMeta.current = true; 48 | bookmarkPage(); 49 | } 50 | }, [id, fetchPage, url, meta, bookmarkPage]); 51 | 52 | return ( 53 | <> 54 | 55 | 56 | 57 | 58 | 59 | 60 | {t('title')} 61 | 62 | 63 | 64 | 65 | 66 | {tags.length !== 0 && ( 67 | 68 | 69 | 70 | )} 71 | 72 | {loading ? ( 73 | 74 | ) : ( 75 | } 78 | /> 79 | )} 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default NotesView; 86 | -------------------------------------------------------------------------------- /packages/extension/src/ui/content-script.js: -------------------------------------------------------------------------------- 1 | import YiNote from './index'; 2 | 3 | const yiNote = new YiNote(); 4 | yiNote.render(); 5 | -------------------------------------------------------------------------------- /packages/extension/src/ui/globalStyle.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | import { APP_ID } from '@yi-note/common/constants'; 3 | 4 | export default createGlobalStyle` 5 | .${APP_ID} :not(svg) { 6 | font-size: 16px; 7 | } 8 | 9 | .${APP_ID} svg { 10 | width: 24px; 11 | height: 24px; 12 | } 13 | 14 | .${APP_ID} .panel-shadow { 15 | -webkit-box-shadow: -6px 9px 11px -2px rgba(0,0,0,0.75); 16 | -moz-box-shadow: -6px 9px 11px -2px rgba(0,0,0,0.75); 17 | box-shadow: -6px 9px 11px -2px rgba(0,0,0,0.75); 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /packages/extension/src/ui/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { default as useSyncNotes } from './useSyncNotes'; 2 | export { default as useLoadScreenshots } from './useLoadScreenshots'; 3 | -------------------------------------------------------------------------------- /packages/extension/src/ui/hooks/useLoadScreenshots.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { useStoreActions } from 'easy-peasy'; 3 | import { delay } from '@yi-note/common/utils'; 4 | import { takeScreenshot } from '../utils'; 5 | import { PlayerFactory } from '../services/player'; 6 | 7 | export default () => { 8 | const [loading, setLoading] = useState(false); 9 | const { saveNote } = useStoreActions(actions => actions.page); 10 | 11 | const loadScreenshots = useCallback( 12 | async (notes, forceLoad = false) => { 13 | setLoading(true); 14 | const player = await PlayerFactory.getPlayer(); 15 | const currentTime = await player.getCurrentTime(); 16 | const videoEl = player.getVideoElement(); 17 | // Take screenshots 18 | for (const note of notes) { 19 | if (note.image && !forceLoad) { 20 | continue; 21 | } 22 | player.seek(note.timestamp); 23 | await delay(500); 24 | note.image = await takeScreenshot(videoEl); 25 | saveNote(note); 26 | } 27 | // Resume back to start time and pause video 28 | player.seek(currentTime); 29 | player.pause(); 30 | setLoading(false); 31 | }, 32 | [saveNote] 33 | ); 34 | 35 | return { loading, loadScreenshots }; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/extension/src/ui/hooks/useSyncNotes.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | import { SyncFactory } from '../services/sync'; 3 | 4 | export default () => { 5 | const syncRef = useRef(null); 6 | const [hasNotes, setHasNotes] = useState(false); 7 | 8 | useEffect(() => { 9 | if (!syncRef.current) { 10 | syncRef.current = SyncFactory.getSyncService(); 11 | const hasNotesToSync = syncRef.current.hasNotes(); 12 | setHasNotes(hasNotesToSync); 13 | } 14 | // eslint-disable-next-line react-hooks/exhaustive-deps 15 | }, [syncRef.current]); 16 | 17 | return syncRef.current 18 | ? { 19 | platform: syncRef.current.platform, 20 | hasNotesToSync: hasNotes, 21 | getNotesToSync: syncRef.current.getNotes.bind(syncRef.current) 22 | } 23 | : {}; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/extension/src/ui/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import { StoreProvider, createStore } from 'easy-peasy'; 5 | import Logger from 'js-logger'; 6 | import { storeModel } from './store'; 7 | import App from './containers/App'; 8 | import GlobalStyle from './globalStyle'; 9 | import PDFGenerator from '@yi-note/common/services/pdf'; 10 | import { APP_ID } from '@yi-note/common/constants'; 11 | import i18n from '../i18n'; 12 | 13 | export default class YiNote { 14 | #store; 15 | 16 | constructor() { 17 | this.#store = createStore(storeModel); 18 | Logger.useDefaults(); 19 | i18n.init(); 20 | PDFGenerator.init(); 21 | } 22 | 23 | get store() { 24 | return this.#store; 25 | } 26 | 27 | render() { 28 | const container = document.createElement('div'); 29 | container.id = APP_ID; 30 | container.classList.add(APP_ID); 31 | document.body.appendChild(container); 32 | 33 | ReactDOM.render( 34 | 35 | 36 | 37 | 38 | 39 | , 40 | container 41 | ); 42 | } 43 | 44 | destroy() { 45 | const yinoteEl = document.getElementById(APP_ID); 46 | if (yinoteEl) { 47 | yinoteEl.parentNode.removeChild(yinoteEl); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/ClassWatcher.js: -------------------------------------------------------------------------------- 1 | const noop = () => {}; 2 | 3 | export default class ClassWatcher { 4 | constructor( 5 | targetNode, 6 | classToWatch, 7 | classAddedCallback, 8 | classRemovedCallback 9 | ) { 10 | this.targetNode = targetNode; 11 | this.classToWatch = classToWatch; 12 | this.classAddedCallback = classAddedCallback || noop; 13 | this.classRemovedCallback = classRemovedCallback || noop; 14 | this.observer = null; 15 | this.lastClassState = targetNode.classList.contains(this.classToWatch); 16 | 17 | this.init(); 18 | } 19 | 20 | init() { 21 | this.observer = new window.MutationObserver(this.mutationCallback); 22 | this.observe(); 23 | } 24 | 25 | observe() { 26 | this.observer.observe(this.targetNode, { attributes: true }); 27 | } 28 | 29 | disconnect() { 30 | this.observer.disconnect(); 31 | } 32 | 33 | mutationCallback = mutationsList => { 34 | for (let mutation of mutationsList) { 35 | if ( 36 | mutation.type === 'attributes' && 37 | mutation.attributeName === 'class' 38 | ) { 39 | let currentClassState = mutation.target.classList.contains( 40 | this.classToWatch 41 | ); 42 | if (this.lastClassState !== currentClassState) { 43 | this.lastClassState = currentClassState; 44 | if (currentClassState) { 45 | this.classAddedCallback(); 46 | } else { 47 | this.classRemovedCallback(); 48 | } 49 | } 50 | } 51 | } 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/getRangeFromElement.js: -------------------------------------------------------------------------------- 1 | import isElement from './isElement'; 2 | 3 | export default el => { 4 | if (!isElement(el)) { 5 | return null; 6 | } 7 | 8 | const range = document.createRange(); 9 | range.selectNodeContents(el); 10 | 11 | return range; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/getRangeFromSelection.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | if (!document) { 3 | return null; 4 | } 5 | 6 | let range; 7 | const selection = document.getSelection(); 8 | if (selection.rangeCount > 0) { 9 | range = selection.getRangeAt(0); 10 | } 11 | 12 | return [range, selection.anchorNode, selection.focusNode]; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/index.js: -------------------------------------------------------------------------------- 1 | export { default as ClassWatcher } from './ClassWatcher'; 2 | export { default as getRangeFromSelection } from './getRangeFromSelection'; 3 | export { default as getRangeFromElement } from './getRangeFromElement'; 4 | export { default as isDescendant } from './isDescendant'; 5 | export { default as isElement } from './isElement'; 6 | export { default as isHidden } from './isHidden'; 7 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/isDescendant.js: -------------------------------------------------------------------------------- 1 | export default (parent, child) => { 2 | if (!parent || !child) { 3 | return false; 4 | } 5 | 6 | let node = child.parentNode; 7 | while (node != null) { 8 | if (node == parent) { 9 | return true; 10 | } 11 | node = node.parentNode; 12 | } 13 | 14 | return false; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/isElement.js: -------------------------------------------------------------------------------- 1 | export default function isElement(o) { 2 | return typeof HTMLElement === 'object' 3 | ? // eslint-disable-next-line no-undef 4 | o instanceof HTMLElement 5 | : o && 6 | typeof o === 'object' && 7 | o !== null && 8 | o.nodeType === 1 && 9 | typeof o.nodeName === 'string'; 10 | } 11 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/dom/isHidden.js: -------------------------------------------------------------------------------- 1 | export default el => { 2 | const style = window.getComputedStyle(el); 3 | return style.display === 'none'; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/EmbedlyPlayer.js: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | // For Embedly API details, please refer to https://docs.embed.ly/docs/playerjs 4 | export default class YoutubeIframePlayer extends Player { 5 | #videoEl; 6 | #player; 7 | 8 | constructor(options = {}) { 9 | super(options); 10 | 11 | this.#videoEl = options.videoEl; 12 | // eslint-disable-next-line no-undef 13 | this.#player = new embedly.Player(this.#videoEl); 14 | this.#player.on('ready', () => { 15 | logger.info('Embedly player ready'); 16 | }); 17 | } 18 | 19 | getVideoElement() { 20 | return this.#videoEl; 21 | } 22 | 23 | play() { 24 | this.#player.play(); 25 | } 26 | 27 | pause() { 28 | this.#player.pause(); 29 | } 30 | 31 | seek(timestamp) { 32 | const timeToSeek = timestamp - this.seekSeconds; 33 | this.#player.setCurrentTime(timeToSeek); 34 | } 35 | 36 | getCurrentTime() { 37 | return new Promise(resolve => { 38 | this.#player.getCurrentTime(currentTime => { 39 | resolve(Math.floor(currentTime)); 40 | }); 41 | }); 42 | } 43 | 44 | getDuration() { 45 | return new Promise(resolve => { 46 | this.#player.getDuration(resolve); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/HTML5Player.js: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | export default class HTML5Player extends Player { 4 | /** 5 | * Create a HTML5Player. Set first find element by selector as video element. 6 | * 7 | * @param {object} [options] 8 | * @param {string} [options.videoEl] - video element 9 | * @param {string} [options.selector] - Css selector to identify video element. 10 | * @throws {Error} - throws error if video element not be found. 11 | */ 12 | constructor(options = {}) { 13 | super(options); 14 | 15 | const { videoEl, selector } = options; 16 | this.video = videoEl || document.querySelector(selector || 'video'); 17 | if (!this.video) { 18 | throw new Error('Player initial error'); 19 | } 20 | 21 | // TODO: move to InstagramPlayer class 22 | // Set crossorigin attribute to enable screenshot 23 | // this.video.setAttribute('crossorigin', 'anonymous'); 24 | // this.video.load(); 25 | } 26 | 27 | getVideoElement() { 28 | return this.video; 29 | } 30 | 31 | play() { 32 | this.video.play(); 33 | } 34 | 35 | pause() { 36 | this.video.pause(); 37 | } 38 | 39 | seek(timestamp) { 40 | const timeToSeek = timestamp - this.seekSeconds; 41 | this.video.currentTime = timeToSeek >= 0 ? timeToSeek : 0; 42 | } 43 | 44 | async getCurrentTime() { 45 | return Promise.resolve(Math.floor(this.video.currentTime)); 46 | } 47 | 48 | async getDuration() { 49 | return Promise.resolve(this.video.duration); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/HookPlayer.js: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | import { APP_ID } from '@yi-note/common/constants'; 3 | 4 | const ACTION_GET_CURRENT_TIME = 'getCurrentTime'; 5 | const ACTION_GET_DURATION = 'getDuration'; 6 | 7 | const sendMessage = message => { 8 | window.postMessage({ ...message, from: APP_ID }, '*'); 9 | }; 10 | 11 | export default class HookPlayer extends Player { 12 | #videoEl; 13 | #resolves; 14 | 15 | constructor(options = {}) { 16 | super(options); 17 | this.#videoEl = options.videoEl; 18 | this.#resolves = {}; 19 | } 20 | 21 | getVideoElement() { 22 | return this.#videoEl; 23 | } 24 | 25 | play() { 26 | sendMessage({ action: 'play' }); 27 | } 28 | 29 | pause() { 30 | sendMessage({ action: 'pause' }); 31 | } 32 | 33 | seek(timestamp) { 34 | const timeToSeek = timestamp - this.seekSeconds; 35 | sendMessage({ action: 'seek', data: timeToSeek }); 36 | } 37 | 38 | #getCurrentTimeHandler = event => { 39 | const { action, data } = event.data; 40 | if (action === ACTION_GET_CURRENT_TIME) { 41 | this.#resolves[action](data); 42 | } 43 | }; 44 | 45 | getCurrentTime() { 46 | return new Promise((resolve, reject) => { 47 | this.#resolves[ACTION_GET_CURRENT_TIME] = resolve; 48 | sendMessage({ action: ACTION_GET_CURRENT_TIME }); 49 | window.removeEventListener('message', this.#getCurrentTimeHandler, false); 50 | window.addEventListener('message', this.#getCurrentTimeHandler, false); 51 | window.setTimeout(() => { 52 | reject(new Error('getCurrentTime timeout')); 53 | }, 500); 54 | }); 55 | } 56 | 57 | #getDurationHandler = event => { 58 | const { action, data } = event.data; 59 | if (action === ACTION_GET_CURRENT_TIME) { 60 | this.#resolves[action](data); 61 | } 62 | }; 63 | 64 | getDuration() { 65 | return new Promise((resolve, reject) => { 66 | this.#resolves[ACTION_GET_DURATION] = resolve; 67 | sendMessage({ action: ACTION_GET_DURATION }); 68 | window.removeEventListener('message', this.#getDurationHandler, false); 69 | window.addEventListener('message', this.#getDurationHandler, false); 70 | window.setTimeout(() => { 71 | reject(new Error('getDuration timeout')); 72 | }, 500); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/Player.js: -------------------------------------------------------------------------------- 1 | import { storage as StorageService } from '@yi-note/common/services'; 2 | import { KEY_VIDEO_SEEK_SECONDS } from '@yi-note/common/constants'; 3 | 4 | export default class Player { 5 | constructor() { 6 | StorageService.getStorage() 7 | .getSettings() 8 | .then(settings => { 9 | this.seekSeconds = +settings[KEY_VIDEO_SEEK_SECONDS] || 0; 10 | }); 11 | } 12 | 13 | getVideoElement() { 14 | logger.warn('Method need to be implemented'); 15 | } 16 | 17 | play() { 18 | logger.warn('Method need to be implemented'); 19 | } 20 | 21 | pause() { 22 | logger.warn('Method need to be implemented'); 23 | } 24 | 25 | seek() { 26 | logger.warn('Method need to be implemented'); 27 | } 28 | 29 | async getCurrentTime() { 30 | logger.warn('Method need to be implemented'); 31 | return Promise.resolve(0); 32 | } 33 | 34 | async getDuration() { 35 | logger.warn('Method need to be implemented'); 36 | return Promise.resolve(0); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/PlayerFactory.js: -------------------------------------------------------------------------------- 1 | import videoUrlParser from 'js-video-url-parser'; 2 | import { retry } from '@yi-note/common/utils'; 3 | import YoutubePlayer from './YoutubePlayer'; 4 | import YoutubeIframePlayer from './YoutubeIframePlayer'; 5 | import EmbedlyPlayer from './EmbedlyPlayer'; 6 | import HTML5Player from './HTML5Player'; 7 | import HookPlayer from './HookPlayer'; 8 | import isHidden from '../dom/isHidden'; 9 | 10 | const playersToTry = [ 11 | { 12 | selector: 'video', 13 | player: HTML5Player 14 | }, 15 | { 16 | selector: 'iframe[src*="youtube"]', 17 | player: YoutubeIframePlayer 18 | }, 19 | { 20 | selector: 'iframe[src*="embedly.com"]', 21 | player: EmbedlyPlayer 22 | }, 23 | { 24 | selector: '[data-yinote="yinote-hook-player"]', 25 | player: HookPlayer 26 | } 27 | ]; 28 | 29 | export default class PlayerFactory { 30 | constructor() {} 31 | 32 | static #player; 33 | 34 | // General way to assign player by selector and class map 35 | static #detectPlayer(resolve, reject) { 36 | let foundSelector; 37 | 38 | const selectorPredicate = () => { 39 | for (const player of playersToTry) { 40 | if (document.querySelectorAll(player.selector).length) { 41 | foundSelector = player.selector; 42 | return true; 43 | } 44 | } 45 | 46 | return false; 47 | }; 48 | 49 | const playerResolver = () => { 50 | // Use first visible element 51 | const playerClass = playersToTry.find( 52 | player => player.selector === foundSelector 53 | ).player; 54 | const els = document.querySelectorAll(foundSelector); 55 | let videoEl; 56 | for (let i = 0; i < els.length; i++) { 57 | const el = els[i]; 58 | if (!isHidden(el)) { 59 | videoEl = el; 60 | break; 61 | } 62 | } 63 | if (videoEl) { 64 | this.#player = new playerClass({ videoEl }); 65 | resolve(this.#player); 66 | return; 67 | } 68 | resolve(null); 69 | }; 70 | 71 | const playerRejector = () => reject(new Error('Failed to find player')); 72 | 73 | retry(selectorPredicate, playerResolver, playerRejector); 74 | } 75 | 76 | static getPlayer(options = {}) { 77 | const { url } = options; 78 | const { id, provider } = videoUrlParser.parse(url) || {}; 79 | 80 | return new Promise((resolve, reject) => { 81 | if (PlayerFactory.#player) { 82 | resolve(PlayerFactory.#player); 83 | return; 84 | } 85 | 86 | if (provider === 'youtube' && id) { 87 | this.#player = new YoutubePlayer(options); 88 | resolve(this.#player); 89 | return; 90 | } 91 | 92 | // Generally detect player in dom 93 | this.#detectPlayer(resolve, reject); 94 | }); 95 | } 96 | 97 | static reset() { 98 | this.#player = null; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/YoutubeIframePlayer.js: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | export default class YoutubeIframePlayer extends Player { 4 | #videoEl; 5 | #player; 6 | 7 | constructor(options = {}) { 8 | super(options); 9 | 10 | this.#videoEl = options.videoEl; 11 | if (!this.#videoEl.id) { 12 | this.#videoEl.id = 'yinote-youtube-iframe'; 13 | } 14 | this.#setIframeQueryParams(); 15 | this.#player = new YT.Player(this.#videoEl.id, { 16 | events: { 17 | onReady: () => logger.info('youtube iframe player ready') 18 | } 19 | }); 20 | } 21 | 22 | #setIframeQueryParams() { 23 | const parsedUrl = new URL(this.#videoEl.src); 24 | const queryParams = new URLSearchParams(parsedUrl.search); 25 | queryParams.set('enablejsapi', '1'); 26 | queryParams.delete('origin'); 27 | parsedUrl.search = queryParams.toString(); 28 | this.#videoEl.src = parsedUrl.toString(); 29 | } 30 | 31 | getVideoElement() { 32 | return this.#videoEl; 33 | } 34 | 35 | play() { 36 | this.#player.playVideo(); 37 | } 38 | 39 | pause() { 40 | this.#player.pauseVideo(); 41 | } 42 | 43 | seek(timestamp) { 44 | const timeToSeek = timestamp - this.seekSeconds; 45 | this.#player.seekTo(timeToSeek >= 0 ? timeToSeek : 0); 46 | } 47 | 48 | async getCurrentTime() { 49 | return new Promise((resolve, reject) => { 50 | const currentTime = this.#player.getCurrentTime(); 51 | resolve(Math.floor(currentTime)); 52 | }); 53 | } 54 | 55 | async getDuration() { 56 | return new Promise((resolve, reject) => { 57 | const duration = this.#player.getDuration(); 58 | resolve(Math.floor(duration)); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/YoutubePlayer.js: -------------------------------------------------------------------------------- 1 | import HTML5Player from './HTML5Player'; 2 | import ClassWatcher from '../dom/ClassWatcher'; 3 | 4 | const ON_AD_CLASSNAME = 'ad-showing'; 5 | 6 | export default class YoutubePlayer extends HTML5Player { 7 | constructor({ onShowingAd, onHidingAd }) { 8 | super(); 9 | 10 | const playerEl = document.querySelector('.html5-video-player'); 11 | if (playerEl) { 12 | // Register class watcher 13 | new ClassWatcher(playerEl, ON_AD_CLASSNAME, onShowingAd, onHidingAd); 14 | 15 | // Check if playerEl start with ads 16 | if (playerEl.classList.contains(ON_AD_CLASSNAME)) { 17 | onShowingAd(); 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/player/index.js: -------------------------------------------------------------------------------- 1 | export { default as PlayerFactory } from './PlayerFactory'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/sync/Coursera.js: -------------------------------------------------------------------------------- 1 | import SyncBase from './SyncBase'; 2 | 3 | class Coursera extends SyncBase { 4 | constructor() { 5 | super(); 6 | this.platform = 'Coursera'; 7 | this.noteSelector = '.rc-Highlight'; 8 | } 9 | 10 | getTimestamp = noteEl => { 11 | const timestampButtonEl = noteEl.querySelector( 12 | '#highlight_timestamp_button' 13 | ); 14 | const timestampStr = timestampButtonEl.getAttribute('aria-labelledby'); 15 | return Math.floor(timestampStr.split('-')[3]); 16 | }; 17 | 18 | getContent = noteEl => { 19 | let content; 20 | const contentEl = noteEl.querySelector('.highlight-note'); 21 | if (contentEl) { 22 | content = contentEl.textContent; 23 | } 24 | return content; 25 | }; 26 | } 27 | 28 | export default Coursera; 29 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/sync/SyncBase.js: -------------------------------------------------------------------------------- 1 | import { TYPE_VIDEO_NOTE } from '@yi-note/common/constants'; 2 | 3 | export default class SyncBase { 4 | hasNotes() { 5 | if (!this.noteSelector || !this.platform) { 6 | return false; 7 | } 8 | return !!document.querySelectorAll(this.noteSelector).length; 9 | } 10 | 11 | async getNotes() { 12 | const notes = []; 13 | try { 14 | const noteEls = document.querySelectorAll(this.noteSelector); 15 | for (const noteEl of noteEls) { 16 | const note = {}; 17 | note.timestamp = await this.getTimestamp(noteEl); 18 | note.content = 19 | (await this.getContent(noteEl)) || 20 | browser.i18n.getMessage('note_empty'); 21 | note.type = TYPE_VIDEO_NOTE; 22 | notes.push(note); 23 | } 24 | } catch (e) { 25 | logger.error('Failed to parse notes'); 26 | } 27 | 28 | return notes; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/sync/SyncFactory.js: -------------------------------------------------------------------------------- 1 | import Coursera from './Coursera'; 2 | import Udemy from './Udemy'; 3 | import SyncBase from './SyncBase'; 4 | 5 | const DOMAIN_CLASS_MAPPING = { 6 | 'coursera.org': Coursera, 7 | 'udemy.com': Udemy 8 | }; 9 | 10 | export default class SyncFactory { 11 | static #service; 12 | 13 | static getSyncService() { 14 | if (this.#service) { 15 | return this.#service; 16 | } 17 | 18 | const { hostname } = window.location; 19 | let serviceClass = SyncBase; 20 | for (let domain in DOMAIN_CLASS_MAPPING) { 21 | if (hostname.includes(domain)) { 22 | serviceClass = DOMAIN_CLASS_MAPPING[domain]; 23 | break; 24 | } 25 | } 26 | this.#service = new serviceClass(); 27 | return this.#service; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/sync/Udemy.js: -------------------------------------------------------------------------------- 1 | import SyncBase from './SyncBase'; 2 | import { PlayerFactory } from '../player'; 3 | 4 | export default class Udemy extends SyncBase { 5 | constructor() { 6 | super(); 7 | this.platform = 'Udemy'; 8 | this.noteSelector = '[data-purpose="video-bookmark-item"]'; 9 | } 10 | 11 | getTimestamp = async noteEl => { 12 | const player = await PlayerFactory.getPlayer(); 13 | const duration = await player.getDuration(); 14 | const progress = parseFloat(noteEl.style.left) / 100.0; 15 | return Math.floor(progress * duration); 16 | }; 17 | 18 | getContent = noteEl => { 19 | let content; 20 | const contentEl = noteEl.querySelector('[data-purpose="content"] textarea'); 21 | if (contentEl) { 22 | content = contentEl.textContent; 23 | } 24 | return content; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /packages/extension/src/ui/services/sync/index.js: -------------------------------------------------------------------------------- 1 | export { default as SyncFactory } from './SyncFactory'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/ui/store/app.js: -------------------------------------------------------------------------------- 1 | import { action } from 'easy-peasy'; 2 | 3 | const appModel = { 4 | open: false, 5 | setOpen: action(state => { 6 | state.open = !state.open; 7 | }), 8 | url: window.location.href, 9 | setUrl: action((state, payload) => { 10 | state.url = payload; 11 | }), 12 | showingAd: false, 13 | setShowingAd: action((state, payload) => { 14 | state.showingAd = payload; 15 | }) 16 | }; 17 | 18 | export default appModel; 19 | -------------------------------------------------------------------------------- /packages/extension/src/ui/store/index.js: -------------------------------------------------------------------------------- 1 | export { default as storeModel } from './store'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/ui/store/search.js: -------------------------------------------------------------------------------- 1 | import { action, thunk, actionOn } from 'easy-peasy'; 2 | import { storage as StorageService } from '@yi-note/common/services'; 3 | import { TYPE_BOOKMARKS, TYPE_NOTES } from '@yi-note/common/constants'; 4 | 5 | const storage = StorageService.getStorage(); 6 | 7 | const searchModel = { 8 | query: '', 9 | setQuery: action((state, query) => { 10 | state.query = query; 11 | }), 12 | onSetQuery: actionOn( 13 | actions => actions.setQuery, 14 | state => { 15 | const { query, type, bookmarks, notes } = state; 16 | if (!query) { 17 | switch (type) { 18 | case TYPE_BOOKMARKS: 19 | state.results = [...bookmarks]; 20 | break; 21 | case TYPE_NOTES: 22 | state.results = [...notes]; 23 | break; 24 | default: 25 | state.results = []; 26 | break; 27 | } 28 | } 29 | } 30 | ), 31 | type: TYPE_BOOKMARKS, 32 | setType: action((state, type) => { 33 | state.type = type; 34 | }), 35 | results: [], 36 | setResults: action((state, results) => { 37 | state.results = [...results]; 38 | }), 39 | bookmarks: [], 40 | setBookmarks: action((state, bookmarks) => { 41 | state.bookmarks = [...bookmarks]; 42 | }), 43 | fetchBookmarks: thunk(async actions => { 44 | const bookmarks = await storage.getBookmarks(); 45 | actions.setBookmarks(bookmarks); 46 | }), 47 | notes: [], 48 | setNotes: action((state, notes) => { 49 | state.notes = [...notes]; 50 | }), 51 | fetchNotes: thunk(async actions => { 52 | const notes = await storage.getNotes(); 53 | actions.setNotes(notes); 54 | }), 55 | search: thunk(async (actions, _, { getState }) => { 56 | const { query, type } = getState(); 57 | let results; 58 | if (type === TYPE_BOOKMARKS) { 59 | results = await storage.searchBookmarks(query); 60 | } else if (type === TYPE_NOTES) { 61 | results = await storage.searchNotes(query); 62 | } 63 | actions.setResults(results); 64 | }) 65 | }; 66 | 67 | export default searchModel; 68 | -------------------------------------------------------------------------------- /packages/extension/src/ui/store/store.js: -------------------------------------------------------------------------------- 1 | import { actionOn, thunkOn } from 'easy-peasy'; 2 | import app from './app'; 3 | import videoNotes from './videoNotes'; 4 | import search from './search'; 5 | import { page, settings, alerts } from '@yi-note/common/store'; 6 | 7 | const storeModel = { 8 | app, 9 | alerts, 10 | videoNotes, 11 | search, 12 | page, 13 | settings, 14 | onSetPage: actionOn( 15 | actions => actions.page.setPage, 16 | state => { 17 | state.search.bookmarks = []; 18 | state.search.notes = []; 19 | } 20 | ), 21 | onSetUrl: thunkOn( 22 | actions => actions.app.setUrl, 23 | async actions => { 24 | actions.videoNotes.reset(); 25 | actions.page.reset(); 26 | } 27 | ) 28 | }; 29 | 30 | export default storeModel; 31 | -------------------------------------------------------------------------------- /packages/extension/src/ui/store/videoNotes.js: -------------------------------------------------------------------------------- 1 | import { action, thunk } from 'easy-peasy'; 2 | 3 | export const defaultNote = { 4 | id: '', 5 | content: '', 6 | timestamp: 0, 7 | image: '' 8 | }; 9 | 10 | const videoNotesModel = { 11 | editor: { 12 | active: false, 13 | note: { ...defaultNote }, 14 | setActive: action((state, active) => { 15 | state.active = active; 16 | }), 17 | setNote: action((state, note) => { 18 | state.note = { ...state.note, ...note }; 19 | }), 20 | reset: action(state => { 21 | state.active = false; 22 | state.note = {}; 23 | }) 24 | }, 25 | support: { 26 | open: false, 27 | setOpen: action((state, payload) => { 28 | state.open = payload; 29 | }) 30 | }, 31 | edit: action((state, note) => { 32 | state.editor.active = true; 33 | state.editor.note = { ...note }; 34 | }), 35 | reset: thunk(async actions => { 36 | actions.editor.reset(); 37 | }) 38 | }; 39 | 40 | export default videoNotesModel; 41 | -------------------------------------------------------------------------------- /packages/extension/src/ui/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as takeScreenshot } from './takeScreenshot'; 2 | -------------------------------------------------------------------------------- /packages/extension/src/ui/utils/takeScreenshot.js: -------------------------------------------------------------------------------- 1 | import { storage as StorageService } from '@yi-note/common/services'; 2 | import { 3 | KEY_SCREENSHOT_RESOLUTION, 4 | SCREENSHOT_RESOLUTION 5 | } from '@yi-note/common/constants'; 6 | 7 | /** 8 | * Take screenshot for provided dom element with dimensions 9 | * Use 360p - 640 * 360 by default 10 | * 11 | * @param element - dom element 12 | * @param width 13 | * @param height 14 | * 15 | * @return image dataUri 16 | */ 17 | export default async element => { 18 | // TODO: pass in options instead of use settings from storage 19 | const settings = await StorageService.getStorage().getSettings(); 20 | const { x, y } = SCREENSHOT_RESOLUTION[ 21 | settings[KEY_SCREENSHOT_RESOLUTION] || 360 22 | ]; 23 | 24 | if (!element) { 25 | logger.error('Missing element'); 26 | return; 27 | } 28 | 29 | const canvas = document.createElement('canvas'); 30 | canvas.width = x; 31 | canvas.height = y; 32 | const ctx = canvas.getContext('2d'); 33 | let imageUri = null; 34 | try { 35 | ctx.drawImage(element, 0, 0, x, y); 36 | imageUri = canvas.toDataURL('image/jpeg'); 37 | } catch (e) { 38 | logger.error(e); 39 | } 40 | 41 | return imageUri; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/extension/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 4 | const FileManagerPlugin = require('filemanager-webpack-plugin'); 5 | const ManifestPlugin = require('webpack-manifest-plugin'); 6 | const Dotenv = require('dotenv-webpack'); 7 | const pkg = require('./package.json'); 8 | const commonManifest = require('./manifest.common.json'); 9 | 10 | const getManifestPlugin = env => { 11 | const seed = commonManifest; 12 | if (env === 'chromium') { 13 | seed.options_page = 'options.html'; 14 | seed.browser = 'chromium'; 15 | } else { 16 | // Use web extension standard as fallback 17 | seed.options_ui = { 18 | page: 'options.html', 19 | browser_style: true 20 | }; 21 | seed.browser = 'firefox'; 22 | seed.permissions.push('downloads'); 23 | seed.permissions.push(''); 24 | } 25 | 26 | return new ManifestPlugin({ 27 | fileName: 'manifest.json', 28 | seed, 29 | generate: seed => seed 30 | }); 31 | }; 32 | 33 | module.exports = env => { 34 | return { 35 | entry: { 36 | content: './src/ui/content-script.js', 37 | background: './src/background/index.js', 38 | options: './src/options/index.js' 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.(js|jsx)$/, 44 | exclude: /node_modules/, 45 | use: { 46 | loader: 'babel-loader', 47 | options: { 48 | rootMode: 'upward' 49 | } 50 | } 51 | }, 52 | { 53 | test: /\.css$/i, 54 | use: ['style-loader', 'css-loader'], 55 | }, 56 | { 57 | test: /\.png$/, 58 | loader: 'file-loader', 59 | options: { 60 | name: 'images/[name].[ext]' 61 | } 62 | }, 63 | { 64 | test: /\.(woff|woff2|eot|ttf|otf)$/, 65 | loader: 'file-loader', 66 | options: { 67 | name: 'assets/fonts/[name].[ext]' 68 | } 69 | }, 70 | { 71 | test: /\.svg$/, 72 | use: [ 73 | { 74 | loader: 'babel-loader', 75 | options: { 76 | rootMode: 'upward' 77 | } 78 | }, 79 | { 80 | loader: 'react-svg-loader', 81 | options: { 82 | jsx: true // true outputs JSX tags 83 | } 84 | } 85 | ] 86 | } 87 | ] 88 | }, 89 | output: { 90 | path: path.resolve(__dirname, 'dist') 91 | }, 92 | plugins: [ 93 | getManifestPlugin(env), 94 | new Dotenv(), 95 | new CopyWebpackPlugin([{ from: 'src/vendors', to: 'vendors' }]), 96 | new HtmlWebPackPlugin({ 97 | template: 'src/options/index.html', 98 | filename: 'options.html', 99 | browserPolyfill: 'vendors/browser-polyfill.js', 100 | chunks: ['options'] 101 | }), 102 | new FileManagerPlugin({ 103 | onEnd: [ 104 | { 105 | copy: [ 106 | { source: 'icons/**/*', destination: 'dist/icons' }, 107 | { source: '_locales/**/*', destination: 'dist/_locales' }, 108 | { source: 'installed.png', destination: 'dist' } 109 | ] 110 | } 111 | ] 112 | }) 113 | ] 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /packages/extension/webpack.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const FileManagerPlugin = require('filemanager-webpack-plugin'); 4 | const common = require('./webpack.common'); 5 | const pkg = require('./package.json'); 6 | 7 | module.exports = env => 8 | merge(common(env), { 9 | mode: 'production', 10 | optimization: { 11 | minimize: false // Evernote SDK has issue when code is minifized 12 | }, 13 | plugins: [ 14 | new CleanWebpackPlugin(), 15 | new FileManagerPlugin({ 16 | onEnd: [ 17 | { 18 | copy: [ 19 | { source: 'icons/**/*', destination: 'dist/icons' }, 20 | { source: '_locales/**/*', destination: 'dist/_locales' }, 21 | { source: 'installed.png', destination: 'dist' } 22 | ] 23 | }, 24 | { mkdir: ['../../artifactory'] }, 25 | { 26 | archive: [ 27 | { 28 | source: 'dist', 29 | destination: `../../artifactory/yi-note-extension_${env}_${pkg.version}.zip` 30 | } 31 | ] 32 | } 33 | ] 34 | }) 35 | ] 36 | }); 37 | -------------------------------------------------------------------------------- /packages/extension/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common'); 3 | 4 | module.exports = env => 5 | merge(common(env), { 6 | mode: 'development', 7 | devtool: 'inline-source-map', 8 | watch: true 9 | }); 10 | -------------------------------------------------------------------------------- /packages/landing/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": [ 4 | "prettier" 5 | ], 6 | "rules": { 7 | "prettier/prettier": "error", 8 | "no-extra-semi": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/landing/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # IntelijIDEA 72 | .idea 73 | -------------------------------------------------------------------------------- /packages/landing/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | YiNote Landing Page 4 | -------------------------------------------------------------------------------- /packages/landing/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: `YiNote`, 4 | description: `Take & share time-stamped notes while watching videos!`, 5 | author: `@shuowu` 6 | }, 7 | plugins: [ 8 | `gatsby-plugin-react-helmet`, 9 | { 10 | resolve: `gatsby-source-filesystem`, 11 | options: { 12 | name: `images`, 13 | path: `${__dirname}/static/images` 14 | } 15 | }, 16 | `gatsby-transformer-sharp`, 17 | `gatsby-plugin-sharp`, 18 | { 19 | resolve: `gatsby-plugin-manifest`, 20 | options: { 21 | name: `gatsby-starter-default`, 22 | short_name: `starter`, 23 | start_url: `/`, 24 | background_color: `#663399`, 25 | theme_color: `#FF54AC`, 26 | display: `minimal-ui`, 27 | icon: `${__dirname}/static/images/favicon.png` 28 | } 29 | }, 30 | `gatsby-plugin-sass`, 31 | { 32 | resolve: 'gatsby-plugin-web-font-loader', 33 | options: { 34 | google: { 35 | families: ['PT Serif'] 36 | }, 37 | custom: { 38 | families: ['Inter'], 39 | urls: ['/fonts/fonts.css'] 40 | } 41 | } 42 | } 43 | // this (optional) plugin enables Progressive Web App + Offline functionality 44 | // To learn more, visit: https://gatsby.dev/offline 45 | // `gatsby-plugin-offline`, 46 | ] 47 | }; 48 | -------------------------------------------------------------------------------- /packages/landing/gatsby-node.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | 4 | exports.onPostBuild = () => { 5 | console.log('Copying locales'); 6 | fs.copySync( 7 | path.join(__dirname, '/src/locales'), 8 | path.join(__dirname, '/public/locales') 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/landing/microsoft-identity-association.json: -------------------------------------------------------------------------------- 1 | { 2 | "associatedApplications": [ 3 | { 4 | "applicationId": "24fa7402-009b-4526-b067-e6de468fbcc0" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/landing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/landing", 3 | "private": true, 4 | "description": "Landing page for YiNote", 5 | "version": "0.1.0", 6 | "dependencies": { 7 | "gatsby": "^2.22.13", 8 | "gatsby-cli": "^2.12.38", 9 | "gatsby-image": "^2.1.2", 10 | "gatsby-plugin-manifest": "^2.4.9", 11 | "gatsby-plugin-offline": "^2.1.1", 12 | "gatsby-plugin-react-helmet": "^3.0.12", 13 | "gatsby-plugin-sass": "^2.0.11", 14 | "gatsby-plugin-sharp": "^2.1.2", 15 | "gatsby-plugin-web-font-loader": "^1.0.4", 16 | "gatsby-source-filesystem": "^2.3.8", 17 | "gatsby-transformer-sharp": "^2.1.20", 18 | "i18next": "^19.4.5", 19 | "i18next-browser-languagedetector": "^4.2.0", 20 | "i18next-xhr-backend": "^3.2.2", 21 | "node-sass": "^4.14.1", 22 | "prop-types": "^15.7.2", 23 | "react": "^16.8.6", 24 | "react-dom": "^16.8.6", 25 | "react-helmet": "^5.2.1", 26 | "react-i18next": "^11.5.0" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^7.1.0", 30 | "eslint-config-react-app": "^5.2.1", 31 | "eslint-loader": "^4.0.2", 32 | "eslint-plugin-prettier": "^3.1.3", 33 | "prettier": "^1.17.1", 34 | "sass": "^1.26.7" 35 | }, 36 | "scripts": { 37 | "build": "gatsby build", 38 | "develop": "gatsby develop", 39 | "format": "prettier --write src/**/*.{js,jsx}", 40 | "start": "npm run develop", 41 | "serve": "gatsby serve" 42 | }, 43 | "prettier": { 44 | "semi": true, 45 | "singleQuote": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/landing/src/components/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'gatsby'; 3 | import logo from '../../static/images/logo.png'; 4 | 5 | const Logo = () => { 6 | return ( 7 |
8 | 9 | {'Logo'} 10 | YiNote 11 | 12 |
13 | ); 14 | } 15 | 16 | export default Logo; 17 | -------------------------------------------------------------------------------- /packages/landing/src/components/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'gatsby'; 3 | import PropTypes from 'prop-types'; 4 | import { useTranslation } from 'react-i18next'; 5 | import Logo from './Logo'; 6 | import chromeLogo from '../../static/images/chrome-48.png'; 7 | import firefoxLogo from '../../static/images/firefox-48.png'; 8 | import edgeLogo from '../../static/images/microsoft-edge-48.png'; 9 | 10 | const Header = ({ siteTitle }) => { 11 | const { t } = useTranslation('header'); 12 | 13 | return ( 14 |
15 |
16 |
17 | 18 | 19 |
20 | Available on: 21 | 22 | chrome 23 | 24 | 25 | firefox 26 | 27 | 28 | edge 29 | 30 |
31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | Header.propTypes = { 38 | siteTitle: PropTypes.string 39 | }; 40 | 41 | Header.defaultProps = { 42 | siteTitle: `` 43 | }; 44 | 45 | export default Header; 46 | -------------------------------------------------------------------------------- /packages/landing/src/components/image.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StaticQuery, graphql } from 'gatsby'; 3 | import Img from 'gatsby-image'; 4 | 5 | /* 6 | * This component is built using `gatsby-image` to automatically serve optimized 7 | * images with lazy loading and reduced file sizes. The image is loaded using a 8 | * `StaticQuery`, which allows us to load the image from directly within this 9 | * component, rather than having to pass the image data down from pages. 10 | * 11 | * For more information, see the docs: 12 | * - `gatsby-image`: https://gatsby.dev/gatsby-image 13 | * - `StaticQuery`: https://gatsby.dev/staticquery 14 | */ 15 | 16 | const Image = () => ( 17 | } 30 | /> 31 | ); 32 | export default Image; 33 | -------------------------------------------------------------------------------- /packages/landing/src/components/layout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout component that queries for data 3 | * with Gatsby's StaticQuery component 4 | * 5 | * See: https://www.gatsbyjs.org/docs/static-query/ 6 | */ 7 | 8 | import React from 'react'; 9 | import PropTypes from 'prop-types'; 10 | import { StaticQuery, graphql, Link } from 'gatsby'; 11 | 12 | import Header from './header'; 13 | import Logo from './Logo'; 14 | import '../../static/styles/main.scss'; 15 | import '../i18n'; 16 | 17 | const Layout = ({ children }) => ( 18 | ( 29 | <> 30 |
31 |
{children}
32 |
33 |
34 |
35 |
36 |
37 | 38 |
39 |

40 | A browser extension that was created with the goal of solving the 41 | problems of note-taking for online video materials. 42 |

43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 |

Support

51 | 63 |
64 |
65 |
66 | 67 |
68 | 69 |
70 |

71 | Copyright {new Date().getFullYear()}, YiNote. All rights reserved. 72 |

73 |
74 |
75 |
76 | 77 | )} 78 | /> 79 | ); 80 | 81 | Layout.propTypes = { 82 | children: PropTypes.node.isRequired 83 | }; 84 | 85 | export default Layout; 86 | -------------------------------------------------------------------------------- /packages/landing/src/components/seo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from 'react'; 9 | import PropTypes from 'prop-types'; 10 | import Helmet from 'react-helmet'; 11 | import { useStaticQuery, graphql } from 'gatsby'; 12 | 13 | function SEO({ description, lang, meta, title }) { 14 | const { site } = useStaticQuery( 15 | graphql` 16 | query { 17 | site { 18 | siteMetadata { 19 | title 20 | description 21 | author 22 | } 23 | } 24 | } 25 | ` 26 | ); 27 | 28 | const metaDescription = description || site.siteMetadata.description; 29 | 30 | return ( 31 | 72 | ); 73 | } 74 | 75 | SEO.defaultProps = { 76 | lang: `en`, 77 | meta: [], 78 | description: `` 79 | }; 80 | 81 | SEO.propTypes = { 82 | description: PropTypes.string, 83 | lang: PropTypes.string, 84 | meta: PropTypes.arrayOf(PropTypes.object), 85 | title: PropTypes.string.isRequired 86 | }; 87 | 88 | export default SEO; 89 | -------------------------------------------------------------------------------- /packages/landing/src/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import Backend from 'i18next-xhr-backend'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import { initReactI18next } from 'react-i18next'; 5 | 6 | i18n 7 | .use(Backend) 8 | .use(LanguageDetector) 9 | .use(initReactI18next) 10 | .init({ 11 | fallbackLng: 'en', 12 | 13 | // have a common namespace used around the full app 14 | ns: ['translations'], 15 | defaultNS: 'translations', 16 | 17 | debug: true, 18 | 19 | interpolation: { 20 | escapeValue: false // not needed for react!! 21 | }, 22 | 23 | react: { 24 | wait: true, 25 | useSuspense: false 26 | } 27 | }); 28 | 29 | export default i18n; 30 | -------------------------------------------------------------------------------- /packages/landing/src/locales/en/header.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "YiNote" 3 | } -------------------------------------------------------------------------------- /packages/landing/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Layout from '../components/layout'; 4 | import SEO from '../components/seo'; 5 | 6 | const NotFoundPage = () => ( 7 | 8 | 9 |
10 |

NOT FOUND

11 |

You just hit a route that doesn't exist... the sadness.

12 |
13 |
14 | ); 15 | 16 | export default NotFoundPage; 17 | -------------------------------------------------------------------------------- /packages/landing/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Layout from '../components/layout'; 3 | import SEO from '../components/seo'; 4 | import featureImage from '../../static/images/hero.png'; 5 | 6 | const IndexPage = () => { 7 | const title = 'Take Time-stamped Notes While Watching Videos!'; 8 | 9 | return ( 10 | 11 | 12 |
13 |
14 |

{title}

15 |

16 | YiNote, aka TurboNote Chrome Extension, is an effective tool to take and share notes while watching online videos. 17 | It's a must-have tool for users who work with online video materials. 18 |

19 | {'Dashboard'} 20 |
21 |
22 | 23 |
24 | ) 25 | }; 26 | 27 | export default IndexPage; 28 | -------------------------------------------------------------------------------- /packages/landing/static/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /packages/landing/static/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /packages/landing/static/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /packages/landing/static/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /packages/landing/static/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 700; 5 | src: url("Inter-Bold.woff2") format("woff2"), 6 | url("Inter-Bold.woff") format("woff"); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Inter'; 11 | font-style: normal; 12 | font-weight: 400; 13 | src: url("Inter-Regular.woff2") format("woff2"), 14 | url("Inter-Regular.woff") format("woff"); 15 | } -------------------------------------------------------------------------------- /packages/landing/static/images/chrome-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/chrome-48.png -------------------------------------------------------------------------------- /packages/landing/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/favicon.png -------------------------------------------------------------------------------- /packages/landing/static/images/firefox-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/firefox-48.png -------------------------------------------------------------------------------- /packages/landing/static/images/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/hero.png -------------------------------------------------------------------------------- /packages/landing/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/logo.png -------------------------------------------------------------------------------- /packages/landing/static/images/microsoft-edge-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/landing/static/images/microsoft-edge-48.png -------------------------------------------------------------------------------- /packages/landing/static/styles/_grid.scss: -------------------------------------------------------------------------------- 1 | /* ==== GRID SYSTEM ==== */ 2 | 3 | .container { 4 | width: 90%; 5 | margin-left: auto; 6 | margin-right: auto; 7 | } 8 | 9 | .row { 10 | position: relative; 11 | width: 100%; 12 | } 13 | 14 | .row [class^="col"] { 15 | float: left; 16 | margin: 0.5rem 2%; 17 | min-height: 0.125rem; 18 | } 19 | 20 | .col-1, 21 | .col-2, 22 | .col-3, 23 | .col-4, 24 | .col-5, 25 | .col-6, 26 | .col-7, 27 | .col-8, 28 | .col-9, 29 | .col-10, 30 | .col-11, 31 | .col-12 { 32 | width: 96%; 33 | } 34 | 35 | .col-1-sm { 36 | width: 4.33%; 37 | } 38 | 39 | .col-2-sm { 40 | width: 12.66%; 41 | } 42 | 43 | .col-3-sm { 44 | width: 21%; 45 | } 46 | 47 | .col-4-sm { 48 | width: 29.33%; 49 | } 50 | 51 | .col-5-sm { 52 | width: 37.66%; 53 | } 54 | 55 | .col-6-sm { 56 | width: 46%; 57 | } 58 | 59 | .col-7-sm { 60 | width: 54.33%; 61 | } 62 | 63 | .col-8-sm { 64 | width: 62.66%; 65 | } 66 | 67 | .col-9-sm { 68 | width: 71%; 69 | } 70 | 71 | .col-10-sm { 72 | width: 79.33%; 73 | } 74 | 75 | .col-11-sm { 76 | width: 87.66%; 77 | } 78 | 79 | .col-12-sm { 80 | width: 96%; 81 | } 82 | 83 | .row::after { 84 | content: ""; 85 | display: table; 86 | clear: both; 87 | } 88 | 89 | .hidden-sm { 90 | display: none; 91 | } 92 | 93 | @media only screen and (min-width: 33.75em) { /* 540px */ 94 | .container { 95 | width: 80%; 96 | } 97 | } 98 | 99 | @media only screen and (min-width: 45em) { /* 720px */ 100 | .col-1 { 101 | width: 4.33%; 102 | } 103 | 104 | .col-2 { 105 | width: 12.66%; 106 | } 107 | 108 | .col-3 { 109 | width: 21%; 110 | } 111 | 112 | .col-4 { 113 | width: 29.33%; 114 | } 115 | 116 | .col-5 { 117 | width: 37.66%; 118 | } 119 | 120 | .col-6 { 121 | width: 46%; 122 | } 123 | 124 | .col-7 { 125 | width: 54.33%; 126 | } 127 | 128 | .col-8 { 129 | width: 62.66%; 130 | } 131 | 132 | .col-9 { 133 | width: 71%; 134 | } 135 | 136 | .col-10 { 137 | width: 79.33%; 138 | } 139 | 140 | .col-11 { 141 | width: 87.66%; 142 | } 143 | 144 | .col-12 { 145 | width: 96%; 146 | } 147 | 148 | .hidden-sm { 149 | display: block; 150 | } 151 | } 152 | 153 | @media only screen and (min-width: 60em) { /* 960px */ 154 | .container { 155 | width: 75%; 156 | max-width: 60rem; 157 | } 158 | } -------------------------------------------------------------------------------- /packages/landing/static/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { 2 | margin: 0; 3 | padding: 0; 4 | border: 0; 5 | font-size: 100%; 6 | font: inherit; 7 | vertical-align: baseline; 8 | } 9 | 10 | a { 11 | text-decoration: none; 12 | } 13 | 14 | /* HTML5 display-role reset for older browsers */ 15 | 16 | article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { 17 | display: block; 18 | } 19 | 20 | body { 21 | line-height: 1; 22 | } 23 | 24 | ol, ul { 25 | list-style: none; 26 | } 27 | 28 | blockquote, q { 29 | quotes: none; 30 | } 31 | 32 | blockquote { 33 | &:before, &:after { 34 | content: ''; 35 | content: none; 36 | } 37 | } 38 | 39 | q { 40 | &:before, &:after { 41 | content: ''; 42 | content: none; 43 | } 44 | } 45 | 46 | table { 47 | border-collapse: collapse; 48 | border-spacing: 0; 49 | } -------------------------------------------------------------------------------- /packages/landing/static/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import "reset"; 2 | @import "grid"; 3 | @import "all"; 4 | -------------------------------------------------------------------------------- /packages/oauth-api/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/oauth-api/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/oauth-api/README.md -------------------------------------------------------------------------------- /packages/oauth-api/apis/evernote-handler.js: -------------------------------------------------------------------------------- 1 | const Evernote = require('evernote'); 2 | 3 | const getClient = () => { 4 | return new Evernote.Client({ 5 | consumerKey: process.env.EVERNOTE_CONSUMER_KEY, 6 | consumerSecret: process.env.EVERNOTE_CONSUMER_SECRET, 7 | sandbox: false 8 | }); 9 | }; 10 | 11 | const getAuthorizeUrl = (event, context, callback) => { 12 | let { redirect_url: redirectUrl } = event.queryStringParameters || {}; 13 | redirectUrl = decodeURIComponent(redirectUrl); 14 | const callbackUrlsWhitelist = Object.keys(process.env) 15 | .filter(key => key.includes('ALLOWED_REDIRECT_URL')) 16 | .map(key => process.env[key]); 17 | if (!callbackUrlsWhitelist.includes(redirectUrl)) { 18 | return callback(null, { 19 | statusCode: 401, 20 | body: JSON.stringify({ message: 'Unauthorized redirect url.' }) 21 | }); 22 | } 23 | 24 | const client = getClient(); 25 | client.getRequestToken(redirectUrl, (err, oauthToken, oauthSecret) => { 26 | if (err) { 27 | console.log(err); 28 | return callback(null, { 29 | statusCode: 500, 30 | body: JSON.stringify({ message: err.message }) 31 | }); 32 | } 33 | 34 | const oauthUrl = client.getAuthorizeUrl(oauthToken); 35 | const response = { 36 | statusCode: 200, 37 | body: JSON.stringify({ 38 | oauthUrl, 39 | oauthToken, 40 | oauthSecret 41 | }) 42 | }; 43 | callback(null, response); 44 | }); 45 | }; 46 | 47 | const getAccessToken = (event, context, callback) => { 48 | let payload; 49 | try { 50 | payload = JSON.parse(event.body); 51 | } catch (e) { 52 | return callback(null, { 53 | statusCode: 400, 54 | body: JSON.stringify({ message: 'Invalid input' }) 55 | }); 56 | } 57 | 58 | const { oauthToken, oauthSecret, verifier } = payload; 59 | const client = getClient(); 60 | client.getAccessToken( 61 | oauthToken, 62 | oauthSecret, 63 | verifier, 64 | (err, accessToken) => { 65 | if (err) { 66 | return callback(null, { 67 | statusCode: 500, 68 | body: JSON.stringify({ message: err.message }) 69 | }); 70 | } 71 | 72 | callback(null, { 73 | statusCode: 200, 74 | body: JSON.stringify({ accessToken }) 75 | }); 76 | } 77 | ); 78 | }; 79 | 80 | module.exports = { 81 | getAuthorizeUrl, 82 | getAccessToken 83 | }; 84 | -------------------------------------------------------------------------------- /packages/oauth-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/oauth-api", 3 | "version": "1.0.0", 4 | "description": "YiNote OAuth Endponts", 5 | "scripts": { 6 | "deploy:dev": "sls deploy --stage dev", 7 | "deploy:prod": "sls deploy --stage production" 8 | }, 9 | "author": "Shuo Wu", 10 | "dependencies": { 11 | "evernote": "^2.0.5" 12 | }, 13 | "devDependencies": { 14 | "eslint-config-node": "^4.0.0", 15 | "serverless-dotenv-plugin": "^2.4.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/oauth-api/serverless.yml: -------------------------------------------------------------------------------- 1 | service: yinote-http-endpoint 2 | 3 | frameworkVersion: ">=1.1.0 <2.0.0" 4 | 5 | plugins: 6 | - serverless-dotenv-plugin 7 | 8 | provider: 9 | name: aws 10 | runtime: nodejs12.x 11 | memorySize: 128 12 | 13 | functions: 14 | evernoteAuthorizeUrl: 15 | handler: apis/evernote-handler.getAuthorizeUrl 16 | events: 17 | - http: 18 | path: evernote/authorize-url 19 | method: get 20 | evernoteAccessToken: 21 | handler: apis/evernote-handler.getAccessToken 22 | events: 23 | - http: 24 | path: evernote/access-token 25 | method: post 26 | -------------------------------------------------------------------------------- /packages/playground/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "plugin:react/recommended" 7 | ], 8 | "plugins": [ 9 | "react-hooks" 10 | ], 11 | "rules": { 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "error" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/playground/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/playground/favicon.ico -------------------------------------------------------------------------------- /packages/playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dev page 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/playground/mov_bbb.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/byteyilabs/yi-note/dc39bebf3e4ed9d71961c56e2d627fb01e5b84fc/packages/playground/mov_bbb.mp4 -------------------------------------------------------------------------------- /packages/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yi-note/playground", 3 | "version": "1.0.0", 4 | "description": "Playground for extension UI widgets", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --env.NODE_ENV=playground --mode development" 8 | }, 9 | "author": "Shuo Wu ", 10 | "license": "GNU GPLv3", 11 | "dependencies": { 12 | "@yi-note/extension": "^1.4.1", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/playground/src/components/Embedly.js: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const StyledContainer = styled.div` 5 | & iframe { 6 | position: absolute; 7 | top: 0; 8 | width: 600px; 9 | height: 100%; 10 | } 11 | `; 12 | 13 | const Embedly = ({ onRender }) => { 14 | useLayoutEffect(() => { 15 | onRender(); 16 | }, [onRender]); 17 | 18 | return ( 19 | 20 |