├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── __tests__ ├── crawl.test.ts ├── data │ ├── euc_jp.html │ └── shift_jis.html ├── es.test.ts └── utils.test.ts ├── build └── .gitignore ├── docs ├── README_ja.md ├── bash │ └── researcher_setup.sh └── windows │ ├── README.md │ ├── README_ja.md │ └── researcher_setup.bat ├── extension └── public │ ├── favicon.ico │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-24.png │ ├── icon-32.png │ ├── icon-36.png │ ├── icon-48.png │ ├── icon-512.png │ ├── icon-72.png │ ├── icon-96.png │ ├── icon.svg │ └── no_image.png ├── jest.config.js ├── jest.js ├── jest.setup.js ├── package.json ├── pnpm-lock.yaml ├── scripts ├── prepare.ts └── utils.ts ├── setupTests.ts ├── shim.d.ts ├── src ├── components │ ├── app │ │ ├── EsCheck.tsx │ │ └── Routing.tsx │ ├── atoms │ │ ├── ColorButton.tsx │ │ ├── ErrorMessage.tsx │ │ ├── FlexBox.tsx │ │ ├── LineClamp.tsx │ │ ├── LineClampText.tsx │ │ ├── LoadableButton.tsx │ │ ├── SpacerDivider.tsx │ │ └── TypographyText.tsx │ ├── controls │ │ ├── ExportButton.tsx │ │ ├── ImportFromHtmlButton.tsx │ │ ├── LanguageSwitcher.tsx │ │ ├── MyRating.tsx │ │ ├── SearchBox.tsx │ │ └── ThemeChangeIconButton.tsx │ ├── dialogs │ │ └── DeleteDialog.tsx │ ├── drawers │ │ ├── BrandMenuLogo.tsx │ │ ├── LeftDrawer.tsx │ │ ├── SmallSidebar.tsx │ │ ├── SmallSidebarItem.tsx │ │ └── list_group │ │ │ └── IconList.tsx │ ├── import │ │ ├── BrowserBookmarksTreeView.tsx │ │ ├── ImportList.tsx │ │ ├── MyTreeItem.tsx │ │ ├── TextAreaDialog.tsx │ │ ├── TreeItemLabel.tsx │ │ └── TreeViewDialog.tsx │ ├── layout │ │ ├── AppLayout.tsx │ │ ├── BrandMenuLogo.tsx │ │ ├── LogoSmall.tsx │ │ └── NavBar.tsx │ ├── popup │ │ ├── AlertBox.tsx │ │ ├── HealthCheck.tsx │ │ ├── PopupAppBar.tsx │ │ └── PopupEsCheck.tsx │ ├── readable │ │ ├── EmbedPlayer.tsx │ │ ├── ReadableArticle.tsx │ │ └── ReadableDialog.tsx │ └── search │ │ ├── BookmarkActions.tsx │ │ ├── ColumnCard.tsx │ │ ├── Description.tsx │ │ ├── Favicon.tsx │ │ ├── HeadlineCard.tsx │ │ ├── PinnedReadLater.tsx │ │ ├── SearchResult.tsx │ │ ├── SimpleItem.tsx │ │ └── ThumnailLink.tsx ├── constants.ts ├── hooks │ ├── useAutoToggleDrawer.ts │ ├── useCustomTheme.ts │ ├── useInfiniteLoad.ts │ ├── useScrollPosistion.ts │ └── useWindowFocus.ts ├── i18n.ts ├── libs │ ├── browsers.ts │ ├── crawl.ts │ ├── dompurify.ts │ ├── elasticsearch.ts │ ├── highlightjs.ts │ ├── plimit.ts │ └── utils.ts ├── locales │ ├── en.json │ └── ja.json ├── manifest.ts ├── pages │ ├── ImportPage.tsx │ ├── SearchPage.tsx │ ├── SettingsPage.tsx │ ├── UpgradePage.tsx │ └── WelcomePage.tsx ├── redux │ ├── rootReducer.ts │ ├── selectors.ts │ ├── services │ │ └── elasticsearch │ │ │ ├── api.ts │ │ │ └── config │ │ │ ├── analysis.ts │ │ │ ├── mappings.ts │ │ │ ├── pipeline.ts │ │ │ └── queries.ts │ ├── slices │ │ ├── deleteDialogSlice.ts │ │ ├── drawerSlice.ts │ │ ├── esConfigSlice.ts │ │ ├── importSlice.ts │ │ ├── readableSlice.ts │ │ ├── scrollSlice.ts │ │ ├── searchSlice.ts │ │ └── settingSlice.ts │ └── store.ts ├── styles │ ├── global.scss │ └── readable.scss ├── themes │ ├── common.ts │ ├── darkTheme.ts │ └── lightTheme.ts ├── types │ └── index.d.ts ├── views │ ├── app │ │ ├── App.tsx │ │ ├── index.html │ │ └── main.tsx │ └── popup │ │ ├── Popup.tsx │ │ ├── index.html │ │ └── main.tsx └── webext │ └── content_scripts.ts ├── tsconfig.json ├── tsconfig.test.json ├── vite.config.content.ts └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | extension/ 4 | .yarn/ 5 | .pnp.js 6 | src/manifest.ts 7 | jest.config.js 8 | jest.setup.js 9 | .idea/ 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "webextensions": true, 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "project": ["./tsconfig.json"], 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["@typescript-eslint", "react", "react-hooks", "import", "unused-imports"], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:react/recommended", 17 | "plugin:react-hooks/recommended", 18 | "plugin:@typescript-eslint/recommended", 19 | "plugin:import/recommended", 20 | "plugin:import/typescript", 21 | "preact", 22 | // ! prettier should be here 23 | "prettier" 24 | ], 25 | "settings": { 26 | "node": { 27 | "tryExtensions": [".tsx"] // append tsx to the list as well 28 | }, 29 | "import/parsers": { 30 | "@typescript-eslint/parser": [".ts", ".tsx"] 31 | }, 32 | "import/resolver": { 33 | "node": { 34 | "extensions": [".ts", ".tsx", ".js", ".jsx"] 35 | }, 36 | "typescript": { 37 | "alwaysTryTypes": true, 38 | "project": "./tsconfig.json" 39 | } 40 | } 41 | }, 42 | "overrides": [ 43 | { 44 | "files": ["*.ts", "*.tsx"], 45 | "rules": { 46 | "no-undef": "off" 47 | } 48 | } 49 | ], 50 | "rules": { 51 | "react/react-in-jsx-scope": 0, 52 | "react/display-name": 0, 53 | "react/prop-types": 0, 54 | "@typescript-eslint/explicit-function-return-type": 0, 55 | "@typescript-eslint/explicit-member-accessibility": 0, 56 | "@typescript-eslint/indent": 0, 57 | "@typescript-eslint/member-delimiter-style": 0, 58 | "@typescript-eslint/no-explicit-any": 0, 59 | "@typescript-eslint/no-var-requires": 0, 60 | "@typescript-eslint/no-use-before-define": 0, 61 | // ! no-unused-imports 62 | // https://www.npmjs.com/package/eslint-plugin-unused-imports 63 | "no-unused-vars": "off", 64 | "@typescript-eslint/no-unused-vars": "off", 65 | "unused-imports/no-unused-imports": "error", 66 | "unused-imports/no-unused-vars": [ 67 | "warn", 68 | { 69 | "vars": "all", 70 | "varsIgnorePattern": "^_", 71 | "args": "after-used", 72 | "argsIgnorePattern": "^_" 73 | } 74 | ], 75 | "no-console": [ 76 | 0, 77 | { 78 | "allow": ["warn", "error"] 79 | } 80 | ], 81 | "react/self-closing-comp": [ 82 | "error", 83 | { 84 | "component": true, 85 | "html": true 86 | } 87 | ], 88 | "@typescript-eslint/consistent-type-imports": [ 89 | "error", 90 | { 91 | "prefer": "type-imports" 92 | } 93 | ], 94 | // ! import 95 | "no-duplicate-imports": "off", 96 | "sort-imports": "off", 97 | "import/no-duplicates": "error", 98 | "import/prefer-default-export": "off", 99 | "import/no-unresolved": "error", 100 | "import/no-extraneous-dependencies": "warn", 101 | // https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/order.md 102 | "import/order": [ 103 | "warn", 104 | { 105 | "alphabetize": { "order": "asc" }, 106 | "newlines-between": "always", 107 | "pathGroups": [ 108 | { 109 | "pattern": "react", 110 | "group": "builtin", 111 | "position": "before" 112 | }, 113 | { 114 | "pattern": "preact", 115 | "group": "builtin", 116 | "position": "before" 117 | } 118 | ], 119 | "pathGroupsExcludedImportTypes": ["react", "preact"] 120 | } 121 | ], 122 | "import/extensions": [ 123 | "error", 124 | "ignorePackages", 125 | { 126 | "js": "never", 127 | "jsx": "never", 128 | "ts": "never", 129 | "tsx": "never" 130 | } 131 | ] 132 | // "@typescript-eslint/no-unused-vars": [ 133 | // 2, 134 | // { 135 | // "argsIgnorePattern": "^_" 136 | // } 137 | // ], 138 | // "no-extend-native": "off", 139 | // "react/jsx-props-no-spreading": "off", 140 | // "jsx-a11y/label-has-associated-control": "off", 141 | // "class-methods-use-this": "off", 142 | // "max-classes-per-file": "off", 143 | // "node/no-missing-import": "off", 144 | // "node/no-unpublished-import": "off", 145 | // "node/no-unsupported-features/es-syntax": [ 146 | // "error", 147 | // { 148 | // "ignores": ["modules"] 149 | // } 150 | // ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vite-ssg-dist 3 | .vite-ssg-temp 4 | *.local 5 | dist 6 | dist-ssr 7 | node_modules 8 | components.d.ts 9 | .idea/ 10 | *.log 11 | extension/manifest.json 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "jsxBracketSameLine": true, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "printWidth": 90, 8 | "proseWrap": "preserve" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "vscode-jest-tests", 6 | "request": "launch", 7 | "console": "integratedTerminal", 8 | "internalConsoleOptions": "neverOpen", 9 | "disableOptimisticBPs": true, 10 | "program": "${workspaceFolder}/jest.js", 11 | "cwd": "${workspaceFolder}", 12 | "env": { "NODE_OPTIONS": "--experimental-vm-modules" }, 13 | "args": ["--runInBand", "--watchAll=false"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "pnpm test", 8 | "type": "shell", 9 | "command": "pnpm test", 10 | "windows": { 11 | "command": "pnpm test" 12 | }, 13 | "group": "test", 14 | "presentation": { 15 | "reveal": "always", 16 | "panel": "shared" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 andots 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 | -------------------------------------------------------------------------------- /__tests__/crawl.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { resolve } from 'path'; 3 | 4 | import { getEncoding } from 'src/libs/crawl'; 5 | 6 | const __dirname = process.cwd(); 7 | 8 | const getTestDataPath = (...args: string[]): string => 9 | resolve(__dirname, '__tests__', 'data', ...args); 10 | 11 | const read = (path: string) => { 12 | const buffer = fs.readFileSync(path); 13 | return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); 14 | }; 15 | 16 | describe('src/libs/crawl', () => { 17 | test('getEncoding shift_jis', async () => { 18 | const buf = read(getTestDataPath('shift_jis.html')); 19 | const encoding = getEncoding(buf); 20 | expect(encoding).toBe('shift_jis'); 21 | }); 22 | 23 | test('getEncoding euc-jp', async () => { 24 | const buf = read(getTestDataPath('euc_jp.html')); 25 | const encoding = getEncoding(buf); 26 | expect(encoding).toBe('euc-jp'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /__tests__/data/euc_jp.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/__tests__/data/euc_jp.html -------------------------------------------------------------------------------- /__tests__/data/shift_jis.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/__tests__/data/shift_jis.html -------------------------------------------------------------------------------- /__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-commented-out-tests */ 2 | import { 3 | analyzeKeywords, 4 | formatDate, 5 | getDecodedShortURL, 6 | hasUrlHash, 7 | isWebUrl, 8 | removeUrlHash, 9 | truncateText, 10 | uniq, 11 | } from 'src/libs/utils'; 12 | 13 | describe('src/libs/utils', () => { 14 | test('getDecodedShortURL', () => { 15 | const a = getDecodedShortURL( 16 | 'https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8', 17 | 100, 18 | ); 19 | expect(a).toBe('https://ja.wikipedia.org/wiki/メインページ'); 20 | const b = getDecodedShortURL( 21 | 'https://seesaawiki.jp/aigis/d/%c6%c3%bc%ec%b9%e7%c0%ae%c9%bd', 22 | 100, 23 | ); 24 | expect(b).toBe('https://seesaawiki.jp/aigis/d/%c6%c3%bc%ec%b9%e7%c0%ae%c9%bd'); 25 | }); 26 | 27 | test('truncate', () => { 28 | const testStr = '1234567890123456789012345'; 29 | const a = truncateText(testStr, 10); 30 | expect(a).toBe('1234567890...'); 31 | }); 32 | 33 | test('uniq', () => { 34 | const array = ['111', '222', '111']; 35 | const array2 = uniq(array); 36 | expect(array2).toEqual(['111', '222']); 37 | }); 38 | 39 | test('isWebUrl', () => { 40 | expect(isWebUrl('fafaveafv')).toBe(false); 41 | expect(isWebUrl('http://www.google.com/')).toBe(true); 42 | expect(isWebUrl('https://www.yahoo.com/')).toBe(true); 43 | expect(isWebUrl('ftp://www.google.com')).toBe(false); 44 | }); 45 | 46 | test('hasUrlHash', () => { 47 | expect(hasUrlHash('aaa')).toBe(false); 48 | expect(hasUrlHash('http://www.google.com')).toBe(false); 49 | expect(hasUrlHash('http://www.google.com/aaa#bbb')).toBe(true); 50 | expect(hasUrlHash('http://www.google.com/?q=a#bbb')).toBe(true); 51 | }); 52 | 53 | test('removeUrlHash', () => { 54 | expect(removeUrlHash('http://www.google.com')).toBe('http://www.google.com/'); 55 | expect(removeUrlHash('http://www.google.com/')).toBe('http://www.google.com/'); 56 | expect(removeUrlHash('http://www.google.com/aaa#bbb')).toBe( 57 | 'http://www.google.com/aaa', 58 | ); 59 | expect(removeUrlHash('http://www.google.com/?q=a#bbb')).toBe( 60 | 'http://www.google.com/?q=a', 61 | ); 62 | expect(() => removeUrlHash('aaa')).toThrowError(); 63 | }); 64 | 65 | test('analyzeKeywords', () => { 66 | const keywords = 'aaa bbb -ccc "ddd"'; 67 | const result = analyzeKeywords(keywords); 68 | expect(result[0]).toEqual({ mode: 'word', word: 'aaa' }); 69 | expect(result[1]).toEqual({ mode: 'word', word: 'bbb' }); 70 | expect(result[2]).toEqual({ mode: 'not', word: 'ccc' }); 71 | expect(result[3]).toEqual({ mode: 'phrase', word: 'ddd' }); 72 | }); 73 | 74 | test('analyzeKeywords including Japanese', () => { 75 | const keywords = 'aaa こんにちは -あいうえお "日本語"'; 76 | const result = analyzeKeywords(keywords); 77 | expect(result[0]).toEqual({ mode: 'word', word: 'aaa' }); 78 | expect(result[1]).toEqual({ mode: 'word', word: 'こんにちは' }); 79 | expect(result[2]).toEqual({ mode: 'not', word: 'あいうえお' }); 80 | expect(result[3]).toEqual({ mode: 'phrase', word: '日本語' }); 81 | }); 82 | 83 | test('formatDate', () => { 84 | const today = new Date(2000, 10, 12); 85 | expect(formatDate(today, 'yyyyMMdd')).toBe('20001112'); 86 | expect(formatDate(today, 'yyyy年MM月dd日')).toBe('2000年11月12日'); 87 | }); 88 | }); 89 | 90 | // import { /* waitFor, screen, RenderResult */ act, render } from '@testing-library/preact'; 91 | // import App from 'src/app/App'; 92 | 93 | // export {}; 94 | 95 | // describe('app', () => { 96 | // beforeEach(() => { 97 | // jest.useFakeTimers(); 98 | // }); 99 | 100 | // afterEach(() => { 101 | // jest.useRealTimers(); 102 | // }); 103 | 104 | // test('adds 1 + 2 to equal 3', () => { 105 | // const expected: number = 3; 106 | // expect(1 + 2).toBe(expected); 107 | // }); 108 | 109 | // test('renders the app', () => { 110 | // const testMessage = 'Learn Preact'; 111 | // const { getByText } = render(); 112 | // expect(getByText(testMessage)).not.toBeNull(); 113 | // }); 114 | 115 | // test('renders the app', () => { 116 | // const testMessage = 'Hello Vite'; 117 | // const testMessage2 = `${testMessage} + Preact!`; 118 | // const { getByText } = render(); 119 | 120 | // expect(getByText(testMessage)).not.toBeNull(); 121 | 122 | // act(() => { 123 | // jest.advanceTimersByTime(1000); 124 | // }); 125 | 126 | // // screen.debug() 127 | // expect(getByText(testMessage2)).not.toBeNull(); 128 | // }); 129 | // }); 130 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /docs/bash/researcher_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SCRIPT_DIR=$(cd $(dirname $0); pwd) 4 | 5 | $SCRIPT_DIR/bin/elasticsearch-plugin install https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v2.1.0/analysis-sudachi-7.10.1-2.1.0.zip 6 | $SCRIPT_DIR/bin/elasticsearch-plugin install analysis-smartcn 7 | $SCRIPT_DIR/bin/elasticsearch-plugin install analysis-nori 8 | 9 | mkdir -p config/sudachi 10 | curl -Lo sudachi-dictionary-20210802-full.zip http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-20210802-full.zip 11 | unzip sudachi-dictionary-20210802-full.zip 12 | mv sudachi-dictionary-20210802/system_full.dic config/sudachi/system_core.dic 13 | rm -rf sudachi-dictionary-20210802-full.zip sudachi-dictionary-20210802/ 14 | 15 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/de_DR.xml 16 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/dictionary-de.txt 17 | mkdir -p config/analysis/de 18 | mv de_DR.xml config/analysis/de 19 | mv dictionary-de.txt config/analysis/de 20 | -------------------------------------------------------------------------------- /docs/windows/README.md: -------------------------------------------------------------------------------- 1 | # How to setup for Windows 10 2 | 3 | [日本語](README_ja.md) 4 | 5 | 1. Install Elasticsearch ver. 7.10.1 6 | 2. Install Elasticsearch Plugins 7 | 3. Save sudachi.json to `config/sudachi/sudachi.json` 8 | 4. Start Elasticsearch 9 | 5. Create indices from our browser extension 10 | 6. (Optional) Install Kibana 11 | 12 | ## 1. Install Elasticsearch ver. 7.10.1 13 | 14 | Currently we only support **Elasticsearch ver. 7.10.1** for our search backend because of the plugins' dependencies. Please make sure your Elasticsearch version is **7.10.1**. 15 | 16 | Download a zip file for Windows from the official site. 17 | 18 | [https://www.elastic.co/downloads/past-releases/elasticsearch-7-10-1](https://www.elastic.co/downloads/past-releases/elasticsearch-7-10-1) 19 | 20 | Unzip the file and locate to e.g. `C:\elasticsearch\elasticsearch-7.10.1`. 21 | 22 | ## 2. Install Elasticsearch Plugins 23 | 24 | ### Use an automation script 25 | 26 | Copy [`researcher_setup.bat`](researcher_setup.bat) in a root folder of Elasticsearch, then execute it. 27 | 28 | ### Manually setup 29 | 30 | Go to the root folder of Elasticsearch, then execute the following command with CMD. 31 | 32 | ```cmd 33 | cd C:\elasticsearch\elasticsearch-7.10.1 34 | 35 | bin\elasticsearch-plugin install https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v2.1.0/analysis-sudachi-7.10.1-2.1.0.zip 36 | 37 | bin\elasticsearch-plugin install analysis-smartcn 38 | 39 | bin\elasticsearch-plugin install analysis-nori 40 | 41 | mkdir config\sudachi 42 | curl -Lo sudachi-dictionary-20210802-full.zip http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-20210802-full.zip 43 | call powershell -command "Expand-Archive sudachi-dictionary-20210802-full.zip" 44 | move sudachi-dictionary-20210802-full\sudachi-dictionary-20210802\system_full.dic config\sudachi\system_core.dic 45 | del sudachi-dictionary-20210802-full.zip 46 | rmdir /s /q sudachi-dictionary-20210802-full 47 | 48 | mkdir config\analysis\de 49 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/de_DR.xml 50 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/dictionary-de.txt 51 | move de_DR.xml config\analysis\de 52 | move dictionary-de.txt config\analysis\de 53 | ``` 54 | 55 | ## 3. Save sudachi.json to `config/sudachi/sudachi.json` 56 | 57 | This file is for Japanse Morphological analysis. 58 | 59 | ```json 60 | { 61 | "systemDict": "system_core.dic", 62 | "inputTextPlugin": [ 63 | { "class": "com.worksap.nlp.sudachi.DefaultInputTextPlugin" }, 64 | { 65 | "class": "com.worksap.nlp.sudachi.ProlongedSoundMarkInputTextPlugin", 66 | "prolongedSoundMarks": ["ー", "-", "⁓", "〜", "〰"], 67 | "replacementSymbol": "ー" 68 | } 69 | ], 70 | "oovProviderPlugin": [ 71 | { "class": "com.worksap.nlp.sudachi.MeCabOovProviderPlugin" }, 72 | { 73 | "class": "com.worksap.nlp.sudachi.SimpleOovProviderPlugin", 74 | "oovPOS": ["補助記号", "一般", "*", "*", "*", "*"], 75 | "leftId": 5968, 76 | "rightId": 5968, 77 | "cost": 3857 78 | } 79 | ], 80 | "pathRewritePlugin": [ 81 | { "class": "com.worksap.nlp.sudachi.JoinNumericPlugin", "joinKanjiNumeric": true }, 82 | { 83 | "class": "com.worksap.nlp.sudachi.JoinKatakanaOovPlugin", 84 | "oovPOS": ["名詞", "普通名詞", "一般", "*", "*", "*"], 85 | "minLength": 3 86 | } 87 | ] 88 | } 89 | ``` 90 | 91 | ## 4. Start Elasticsearch 92 | 93 | Execute `bin\elasticsearch.bat` to start Elasticsearch. 94 | 95 | You can automatically start Elasticsearch as a Windows service if you run the following command. 96 | 97 | To install the service, 98 | 99 | ```cmd 100 | bin\elasticsearch-service.bat install 101 | ``` 102 | 103 | To remove the service, 104 | 105 | ```cmd 106 | bin\elasticsearch-service.bat remove 107 | ``` 108 | 109 | Please see the official document to find more information. 110 | 111 | [Install Elasticsearch with .zip on Windows | Elasticsearch Guide \[7.15\] | Elastic](https://www.elastic.co/guide/en/elasticsearch/reference/current/zip-windows.html) 112 | 113 | ## 5. Create indices from our browser extension 114 | 115 | Finally, create indices for RE:SEARCHER from our browser extension. Open app and click the button "CREATE INDICES". 116 | 117 | ## 6. (Optional) Install Kibana 118 | 119 | If you need Kibana, downalod it from the the below. The version of Elasticsearch and Kibana must be the same. 120 | 121 | [https://www.elastic.co/downloads/past-releases/kibana-7-10-1](https://www.elastic.co/downloads/past-releases/kibana-7-10-1) 122 | -------------------------------------------------------------------------------- /docs/windows/README_ja.md: -------------------------------------------------------------------------------- 1 | # Windows 10 セットアップ手順 2 | 3 | 1. Elasticsearch ver. 7.10.1 のインストール 4 | 2. プラグインのインストール 5 | 3. Sudachi 設定ファイルの配置 6 | 4. Elasticsearch の起動 7 | 5. ブラウザ拡張からインデックスを作成する 8 | 6. (オプション) Kibana のインストール 9 | 10 | ## 1. Elasticsearch ver. 7.10.1 のインストール 11 | 12 | 利用するプラグインの都合上、Elasticsearch のバージョンを「**7.10.1**」に限定しています。バージョンが異なると正しくセットアップが行えませんので、ご注意ください。 13 | 14 | 以下の URL より Elasticsearch 本体の zip ファイルをダウンロードしてください。 15 | 16 | [https://www.elastic.co/downloads/past-releases/elasticsearch-7-10-1](https://www.elastic.co/downloads/past-releases/elasticsearch-7-10-1) 17 | 18 | ダウンロードした zip ファイルを以下のような場所に解凍して配置します。アクセス権限の問題が発生しない場所であれば、基本的にはどこでも構いません。 19 | 20 | `C:\elasticsearch\elasticsearch-7.10.1` 21 | 22 | ## 2. プラグインのインストール 23 | 24 | ### 自動化スクリプトを利用する 25 | 26 | [`researcher_setup.bat`](researcher_setup.bat)を解凍したフォルダの直下にコピーして、実行してください。 27 | 28 | ### 手動で行う 29 | 30 | コマンドプロンプトで解凍した場所に移動し、以下のコマンドを実行してください。 31 | 32 | ```cmd 33 | cd C:\elasticsearch\elasticsearch-7.10.1 34 | 35 | bin\elasticsearch-plugin install https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v2.1.0/analysis-sudachi-7.10.1-2.1.0.zip 36 | 37 | bin\elasticsearch-plugin install analysis-smartcn 38 | 39 | bin\elasticsearch-plugin install analysis-nori 40 | 41 | mkdir config\sudachi 42 | curl -Lo sudachi-dictionary-20210802-full.zip http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-20210802-full.zip 43 | call powershell -command "Expand-Archive sudachi-dictionary-20210802-full.zip" 44 | move sudachi-dictionary-20210802-full\sudachi-dictionary-20210802\system_full.dic config\sudachi\system_core.dic 45 | del sudachi-dictionary-20210802-full.zip 46 | rmdir /s /q sudachi-dictionary-20210802-full 47 | 48 | mkdir config\analysis\de 49 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/de_DR.xml 50 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/dictionary-de.txt 51 | move de_DR.xml config\analysis\de 52 | move dictionary-de.txt config\analysis\de 53 | ``` 54 | 55 | ## 3. Sudachi 設定ファイルの配置 56 | 57 | `config\sudachi`ディレクトリに、以下の json ファイルを`sudachi.json`と名前をつけ配置してください。 58 | 59 | ```json 60 | { 61 | "systemDict": "system_core.dic", 62 | "inputTextPlugin": [ 63 | { "class": "com.worksap.nlp.sudachi.DefaultInputTextPlugin" }, 64 | { 65 | "class": "com.worksap.nlp.sudachi.ProlongedSoundMarkInputTextPlugin", 66 | "prolongedSoundMarks": ["ー", "-", "⁓", "〜", "〰"], 67 | "replacementSymbol": "ー" 68 | } 69 | ], 70 | "oovProviderPlugin": [ 71 | { "class": "com.worksap.nlp.sudachi.MeCabOovProviderPlugin" }, 72 | { 73 | "class": "com.worksap.nlp.sudachi.SimpleOovProviderPlugin", 74 | "oovPOS": ["補助記号", "一般", "*", "*", "*", "*"], 75 | "leftId": 5968, 76 | "rightId": 5968, 77 | "cost": 3857 78 | } 79 | ], 80 | "pathRewritePlugin": [ 81 | { "class": "com.worksap.nlp.sudachi.JoinNumericPlugin", "joinKanjiNumeric": true }, 82 | { 83 | "class": "com.worksap.nlp.sudachi.JoinKatakanaOovPlugin", 84 | "oovPOS": ["名詞", "普通名詞", "一般", "*", "*", "*"], 85 | "minLength": 3 86 | } 87 | ] 88 | } 89 | ``` 90 | 91 | ## 4. Elasticsearch の起動 92 | 93 | `bin\elasticsearch.bat`を実行、またはダブルクリックすると Elasticsearch が起動します。 94 | 95 | 以下のコマンドを実行して、Windows サービスへ登録すれば、ログイン後 Elasticsearch を自動起動させることも可能です。不要な場合は、remove コマンドでサービス登録を解除できます。 96 | 97 | ```cmd 98 | bin\elasticsearch-service.bat install 99 | ``` 100 | 101 | ```cmd 102 | bin\elasticsearch-service.bat remove 103 | ``` 104 | 105 | 詳細は、以下の公式ドキュメントを参考にしてください。 106 | 107 | [Install Elasticsearch with .zip on Windows | Elasticsearch Guide \[7.15\] | Elastic](https://www.elastic.co/guide/en/elasticsearch/reference/current/zip-windows.html) 108 | 109 | ## 5. ブラウザ拡張からインデックスを作成する 110 | 111 | 最後に、Elasticsearch を起動させた状態で、RE:SEARCHER ブラウザ拡張を開き、「インデックス作成」ボタンをクリックすれば、セットアップは完了です。 112 | 113 | ## 6. (オプション) Kibana のインストール 114 | 115 | Kibana が必要な場合は、以下よりダウンロードしてください。(Elasticsearch とバージョンを合わせる必要があります) 116 | 117 | [https://www.elastic.co/downloads/past-releases/kibana-7-10-1](https://www.elastic.co/downloads/past-releases/kibana-7-10-1) 118 | -------------------------------------------------------------------------------- /docs/windows/researcher_setup.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | cd %~dp0 4 | 5 | rem move sudachi.json config\sudachi\sudachi.json 6 | 7 | rem Japanase Analyzer 8 | call bin\elasticsearch-plugin.bat install --batch https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v2.1.0/analysis-sudachi-7.10.1-2.1.0.zip 9 | 10 | rem Chinese Analyzer 11 | call bin\elasticsearch-plugin.bat install --batch analysis-smartcn 12 | 13 | rem Korean Analyzer 14 | call bin\elasticsearch-plugin.bat install --batch analysis-nori 15 | 16 | mkdir config\sudachi 17 | curl -Lo sudachi-dictionary-20210802-full.zip http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-20210802-full.zip 18 | call powershell -command "Expand-Archive sudachi-dictionary-20210802-full.zip" 19 | move sudachi-dictionary-20210802-full\sudachi-dictionary-20210802\system_full.dic config\sudachi\system_core.dic 20 | del sudachi-dictionary-20210802-full.zip 21 | rmdir /s /q sudachi-dictionary-20210802-full 22 | 23 | rem German Dictionary 24 | mkdir config\analysis\de 25 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/de_DR.xml 26 | curl -LO https://raw.githubusercontent.com/uschindler/german-decompounder/master/dictionary-de.txt 27 | move de_DR.xml config\analysis\de 28 | move dictionary-de.txt config\analysis\de 29 | -------------------------------------------------------------------------------- /extension/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/favicon.ico -------------------------------------------------------------------------------- /extension/public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-128.png -------------------------------------------------------------------------------- /extension/public/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-16.png -------------------------------------------------------------------------------- /extension/public/icon-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-24.png -------------------------------------------------------------------------------- /extension/public/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-32.png -------------------------------------------------------------------------------- /extension/public/icon-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-36.png -------------------------------------------------------------------------------- /extension/public/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-48.png -------------------------------------------------------------------------------- /extension/public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-512.png -------------------------------------------------------------------------------- /extension/public/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-72.png -------------------------------------------------------------------------------- /extension/public/icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/icon-96.png -------------------------------------------------------------------------------- /extension/public/no_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andots/researcher-webextension/f88a2790daf31d07b4a2d3bde7024f663c5760a8/extension/public/no_image.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | 3 | module.exports = { 4 | // preset: 'ts-jest', 5 | preset: 'ts-jest/presets/default-esm', 6 | // preset: 'ts-jest/presets/js-with-ts-esm', 7 | // preset: 'ts-jest/presets/js-with-ts', 8 | testEnvironment: 'jsdom', 9 | // testEnvironment: 'node', 10 | roots: ['/src', '/__tests__'], 11 | testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'], 12 | transform: { 13 | '^.+\\.(ts|tsx)$': 'ts-jest', 14 | }, 15 | moduleNameMapper: { 16 | '^src/(.*)$': '/src/$1', 17 | }, 18 | setupFiles: ['jest-webextension-mock'], 19 | setupFilesAfterEnv: ['./jest.setup.js'], 20 | globals: { 21 | 'ts-jest': { 22 | tsconfig: 'tsconfig.test.json', 23 | useESM: true, 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /jest.js: -------------------------------------------------------------------------------- 1 | require('jest-cli/bin/jest'); 2 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // const fetchPolifill = require('whatwg-fetch'); 2 | 3 | // global.fetch = fetchPolifill.fetch; 4 | // global.Request = fetchPolifill.Request; 5 | // global.Headers = fetchPolifill.Headers; 6 | // global.Response = fetchPolifill.Response; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "researcher-webextension", 3 | "displayName": "RE:SEARCHER", 4 | "description": "RE:SEARCHER is a personal search engine for your bookmarks.", 5 | "version": "0.10.0", 6 | "author": "andots", 7 | "license": "MIT", 8 | "homepage": "https://github.com/andots/researcher-webextension", 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:andots/researcher-webextension.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/andots/researcher-webextension/issues" 15 | }, 16 | "keywords": [ 17 | "search", 18 | "bookmarks", 19 | "fulltext-search", 20 | "elasticsearch", 21 | "react", 22 | "preact", 23 | "webextension" 24 | ], 25 | "scripts": { 26 | "preinstall": "npx only-allow pnpm", 27 | "dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*", 28 | "dev:prepare": "esno scripts/prepare.ts", 29 | "dev:web": "vite", 30 | "dev:js": "npm run build:js -- --mode development", 31 | "build": "cross-env NODE_ENV=production run-s clear build:prepare build:web build:js", 32 | "build:prepare": "esno scripts/prepare.ts", 33 | "build:web": "vite build --config vite.config.ts", 34 | "build:js": "vite build --config vite.config.content.ts", 35 | "zip": "run-s zip:webext zip:archive", 36 | "zip:webext": "web-ext build --artifacts-dir=./build --source-dir=./extension --overwrite-dest --no-config-discovery", 37 | "zip:archive": "git archive --format=zip --output=./build/source.zip HEAD", 38 | "test": "node --experimental-vm-modules jest.js", 39 | "clear": "rimraf extension/dist extension/manifest.json extension.*", 40 | "lint": "eslint src/**/*.{json,ts,tsx,js}", 41 | "release": "run-s build zip" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.16.0", 45 | "@preact/preset-vite": "^2.1.5", 46 | "@testing-library/jest-dom": "^5.15.0", 47 | "@testing-library/preact": "^2.0.1", 48 | "@types/dompurify": "^2.3.1", 49 | "@types/fs-extra": "^9.0.13", 50 | "@types/jest": "^27.0.3", 51 | "@types/node": "^16.11.9", 52 | "@types/react": "^17.0.36", 53 | "@types/react-router-dom": "^5.3.2", 54 | "@types/react-window": "^1.8.5", 55 | "@types/redux-persist-webextension-storage": "^1.0.0", 56 | "@types/webextension-polyfill": "^0.8.1", 57 | "@typescript-eslint/eslint-plugin": "^4.33.0", 58 | "@typescript-eslint/parser": "^4.33.0", 59 | "chokidar": "^3.5.2", 60 | "cross-env": "^7.0.3", 61 | "eslint": "^7.32.0", 62 | "eslint-config-preact": "^1.2.0", 63 | "eslint-config-prettier": "^8.3.0", 64 | "eslint-import-resolver-typescript": "^2.5.0", 65 | "eslint-plugin-import": "^2.25.3", 66 | "eslint-plugin-jsx-a11y": "^6.5.1", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-react": "^7.27.1", 69 | "eslint-plugin-react-hooks": "^4.3.0", 70 | "eslint-plugin-unused-imports": "^1.1.5", 71 | "esno": "^0.9.1", 72 | "fs-extra": "^10.0.0", 73 | "jest": "^27.3.1", 74 | "jest-webextension-mock": "^3.7.19", 75 | "kolorist": "^1.5.0", 76 | "npm-run-all": "^4.1.5", 77 | "preact": "^10.5.15", 78 | "prettier": "^2.4.1", 79 | "react": "^17.0.2", 80 | "react-dom": "^17.0.2", 81 | "redux": "^4.1.2", 82 | "rimraf": "^3.0.2", 83 | "rollup-plugin-copy": "^3.4.0", 84 | "rollup-plugin-visualizer": "^5.5.2", 85 | "sass": "^1.43.4", 86 | "ts-jest": "^27.0.7", 87 | "tsup": "^5.7.4", 88 | "typescript": "^4.5.2", 89 | "vite": "^2.6.14", 90 | "web-ext": "^6.5.0", 91 | "webext-bridge": "^5.0.1", 92 | "webextension-polyfill": "^0.8.0" 93 | }, 94 | "dependencies": { 95 | "@elastic/elasticsearch": "^7.15.0", 96 | "@emotion/react": "^11.6.0", 97 | "@emotion/styled": "^11.6.0", 98 | "@mozilla/readability": "^0.4.1", 99 | "@mui/icons-material": "^5.1.1", 100 | "@mui/lab": "^5.0.0-alpha.55", 101 | "@mui/material": "^5.1.1", 102 | "@reduxjs/toolkit": "^1.6.2", 103 | "buffer": "^6.0.3", 104 | "clsx": "^1.1.1", 105 | "deepmerge": "^4.2.2", 106 | "dompurify": "^2.3.3", 107 | "escape-goat": "^4.0.0", 108 | "highlight.js": "^11.3.1", 109 | "i18next": "^21.5.2", 110 | "i18next-browser-languagedetector": "^6.1.2", 111 | "interweave": "^12.9.0", 112 | "p-limit": "^4.0.0", 113 | "react-i18next": "^11.14.2", 114 | "react-redux": "^7.2.6", 115 | "react-router-dom": "^5.3.0", 116 | "react-use": "^17.3.1", 117 | "react-window": "^1.8.6", 118 | "redux-persist": "^6.0.0", 119 | "redux-persist-webextension-storage": "^1.0.2", 120 | "serialize-error": "^8.1.0", 121 | "tree-model": "^1.0.7" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | // generate stub index.html files for dev entry 2 | import chokidar from 'chokidar'; 3 | import fs from 'fs-extra'; 4 | 5 | import { getManifest } from '../src/manifest'; 6 | 7 | import { r, port, isDev, log } from './utils'; 8 | 9 | /** 10 | * Stub index.html to use Vite in development 11 | */ 12 | async function stubIndexHtml() { 13 | const views = ['app', 'popup']; 14 | const srcViewPath = 'src/views'; 15 | const extensionViewPath = 'extension/dist/views'; 16 | 17 | for (const view of views) { 18 | await fs.ensureDir(r(`${extensionViewPath}/${view}`)); 19 | let data = await fs.readFile(r(`${srcViewPath}/${view}/index.html`), 'utf-8'); 20 | data = data.replace( 21 | '"./main.tsx"', 22 | `"http://localhost:${port}/views/${view}/main.tsx"`, 23 | ); 24 | // .replace('
', '
Vite server did not start
') 25 | await fs.writeFile(r(`${extensionViewPath}/${view}/index.html`), data, 'utf-8'); 26 | log('PRE', `stub ${view}`); 27 | } 28 | } 29 | 30 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 31 | export async function writeManifest() { 32 | await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 }); 33 | log('PRE', 'write manifest.json'); 34 | } 35 | 36 | writeManifest(); 37 | 38 | if (isDev) { 39 | stubIndexHtml(); 40 | chokidar.watch(r('src/views/**/*.html')).on('change', () => { 41 | stubIndexHtml(); 42 | }); 43 | chokidar.watch([r('src/manifest.ts'), r('package.json')]).on('change', () => { 44 | writeManifest(); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | import { bgCyan, black } from 'kolorist'; 4 | 5 | export const port = parseInt(process.env.PORT || '', 10) || 3303; 6 | export const r = (...args: string[]): string => resolve(__dirname, '..', ...args); 7 | export const isDev = process.env.NODE_ENV !== 'production'; 8 | 9 | export function log(name: string, message: string): void { 10 | // eslint-disable-next-line no-console 11 | console.log(black(bgCyan(` ${name} `)), message); 12 | } 13 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | // NOTE: 2 | // needs to be actived in jest.config.js 3 | 4 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 5 | // allows you to do things like: 6 | // expect(element).toHaveTextContent(/react/i) 7 | // learn more: https://github.com/testing-library/jest-dom 8 | import '@testing-library/jest-dom'; 9 | -------------------------------------------------------------------------------- /shim.d.ts: -------------------------------------------------------------------------------- 1 | // import type { ProtocolWithReturn } from 'webext-bridge'; 2 | 3 | import type { Bookmark } from 'src/types'; 4 | 5 | declare module 'webext-bridge' { 6 | export interface ProtocolMap { 7 | // define message protocol types 8 | // see https://github.com/antfu/webext-bridge#type-safe-protocols 9 | // 'tab-prev': { title: string | undefined }; 10 | // 'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title: string }>; 11 | create: Bookmark; 12 | update: Bookmark; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/app/EsCheck.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from '@mui/material'; 2 | import { useEffectOnce } from 'react-use'; 3 | 4 | import FlexBox from 'src/components/atoms/FlexBox'; 5 | import UpgradePage from 'src/pages/UpgradePage'; 6 | import WelcomePage from 'src/pages/WelcomePage'; 7 | import { validateElasticsearch } from 'src/redux/slices/esConfigSlice'; 8 | import { useAppDispatch, useAppSelector } from 'src/redux/store'; 9 | 10 | type Props = { 11 | children: React.ReactElement; 12 | }; 13 | 14 | function EsCheck({ children }: Props): JSX.Element { 15 | const dispatch = useAppDispatch(); 16 | const { isReady, isRequiredSetup, isRequiredMigration } = useAppSelector( 17 | (s) => s.esconfig, 18 | ); 19 | 20 | useEffectOnce(() => { 21 | dispatch(validateElasticsearch()); 22 | }); 23 | 24 | if (isReady) { 25 | return children; 26 | } 27 | 28 | if (isRequiredSetup) { 29 | return ; 30 | } 31 | 32 | if (isRequiredMigration) { 33 | return ; 34 | } 35 | 36 | return ( 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default EsCheck; 44 | -------------------------------------------------------------------------------- /src/components/app/Routing.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense } from 'react'; 2 | 3 | import { Route, Switch } from 'react-router-dom'; 4 | 5 | import { ROUTE_HOME, ROUTE_IMPORT, ROUTE_SEARCH, ROUTE_SETTINGS } from 'src/constants'; 6 | 7 | const SearchPage = lazy(() => import('src/pages/SearchPage')); 8 | const ImportPage = lazy(() => import('src/pages/ImportPage')); 9 | const SettingsPage = lazy(() => import('src/pages/SettingsPage')); 10 | 11 | function Routing(): JSX.Element { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | {/* component will be remounted each render */} 19 | {/* */} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default Routing; 35 | -------------------------------------------------------------------------------- /src/components/atoms/ColorButton.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from '@mui/material'; 2 | 3 | import LoadableButton from 'src/components/atoms/LoadableButton'; 4 | 5 | type StyleProps = { 6 | backgroundColor: string; 7 | hoverColor: string; 8 | }; 9 | 10 | type Props = { 11 | text: string; 12 | isLoading?: boolean; 13 | } & ButtonProps & 14 | StyleProps; 15 | 16 | ColorButton.defaultProps = { 17 | isLoading: false, 18 | }; 19 | 20 | function ColorButton({ 21 | text, 22 | backgroundColor, 23 | hoverColor, 24 | isLoading, 25 | ...args 26 | }: Props): JSX.Element { 27 | return ( 28 | theme.palette.getContrastText(backgroundColor), 31 | backgroundColor, 32 | '&:hover': { 33 | backgroundColor: hoverColor, 34 | }, 35 | }} 36 | isLoading={isLoading} 37 | text={text} 38 | {...args} 39 | /> 40 | ); 41 | } 42 | 43 | export default ColorButton; 44 | -------------------------------------------------------------------------------- /src/components/atoms/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import type { SerializedError } from '@reduxjs/toolkit'; 2 | import type { FetchBaseQueryError } from '@reduxjs/toolkit/query/react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | import FlexBox from 'src/components/atoms/FlexBox'; 6 | import TypographyText from 'src/components/atoms/TypographyText'; 7 | 8 | type Props = { 9 | error: FetchBaseQueryError | SerializedError; 10 | }; 11 | 12 | function ErrorMessage({ error }: Props): JSX.Element { 13 | const { t } = useTranslation(); 14 | 15 | return ( 16 | 17 | 18 |
19 |
{JSON.stringify(error, null, 2)}
20 |
21 |
22 | ); 23 | } 24 | 25 | export default ErrorMessage; 26 | -------------------------------------------------------------------------------- /src/components/atoms/FlexBox.tsx: -------------------------------------------------------------------------------- 1 | import type { BoxProps } from '@mui/material'; 2 | import { Box } from '@mui/material'; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | } & BoxProps; 7 | 8 | function FlexBox({ children, ...args }: Props): JSX.Element { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | 16 | export default FlexBox; 17 | -------------------------------------------------------------------------------- /src/components/atoms/LineClamp.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | 3 | // WebkitBoxOrient https://developer.mozilla.org/en-US/docs/Web/CSS/box-orient 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | lineClamp: number; 8 | }; 9 | 10 | function LineClamp({ children, lineClamp }: Props): JSX.Element { 11 | return ( 12 | 30 | {children} 31 | 32 | ); 33 | } 34 | 35 | export default LineClamp; 36 | -------------------------------------------------------------------------------- /src/components/atoms/LineClampText.tsx: -------------------------------------------------------------------------------- 1 | import type { TypographyProps } from '@mui/material'; 2 | 3 | import LineClamp from 'src/components/atoms/LineClamp'; 4 | import TypographyText from 'src/components/atoms/TypographyText'; 5 | 6 | type Props = { 7 | text: string; 8 | lineClamp: number; 9 | } & TypographyProps; 10 | 11 | function LineClampText({ text, lineClamp, ...args }: Props): JSX.Element { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default LineClampText; 20 | -------------------------------------------------------------------------------- /src/components/atoms/LoadableButton.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from '@mui/material'; 2 | import { Button, CircularProgress } from '@mui/material'; 3 | import { grey } from '@mui/material/colors'; 4 | 5 | type Props = { 6 | text: string; 7 | isLoading?: boolean; 8 | } & ButtonProps; 9 | 10 | LoadableButton.defaultProps = { 11 | isLoading: false, 12 | }; 13 | 14 | function LoadableButton({ isLoading, text, ...rest }: Props): JSX.Element { 15 | const { disabled, ...props } = rest; 16 | 17 | return ( 18 | 19 | 20 | 23 | 24 | {isLoading && ( 25 | 36 | )} 37 | 38 | ); 39 | } 40 | 41 | export default LoadableButton; 42 | -------------------------------------------------------------------------------- /src/components/atoms/SpacerDivider.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider } from '@mui/material'; 2 | 3 | type Props = { 4 | spacing?: number; 5 | }; 6 | 7 | function SpacerDivider({ spacing = 0 }: Props): JSX.Element { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default SpacerDivider; 16 | -------------------------------------------------------------------------------- /src/components/atoms/TypographyText.tsx: -------------------------------------------------------------------------------- 1 | import type { TypographyProps } from '@mui/material'; 2 | import { Typography } from '@mui/material'; 3 | 4 | type Props = { 5 | text: string; 6 | } & TypographyProps; 7 | 8 | function TypographyText({ text, ...args }: Props): JSX.Element { 9 | return {text}; 10 | } 11 | 12 | export default TypographyText; 13 | -------------------------------------------------------------------------------- /src/components/controls/ExportButton.tsx: -------------------------------------------------------------------------------- 1 | import type { estypes } from '@elastic/elasticsearch'; 2 | import { Button } from '@mui/material'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | import { INDEX_NAME } from 'src/constants'; 6 | import { formatDate, makeNetscapeBookmarksHtml } from 'src/libs/utils'; 7 | import { 8 | useClosePitMutation, 9 | useExportMutation, 10 | useOpenPitMutation, 11 | } from 'src/redux/services/elasticsearch/api'; 12 | import type { BookmarkResponse, UrlAndTitle } from 'src/types'; 13 | 14 | function ExportButton(): JSX.Element { 15 | const { t } = useTranslation(); 16 | const [getExport, {}] = useExportMutation(); 17 | const [openPit, {}] = useOpenPitMutation(); 18 | const [closePit, {}] = useClosePitMutation(); 19 | 20 | const handleExport = async () => { 21 | try { 22 | const keepAlive = '1m'; 23 | const pitResponse = await openPit({ 24 | index: `${INDEX_NAME}*`, 25 | keep_alive: keepAlive, 26 | }).unwrap(); 27 | const pitId = pitResponse.id; 28 | 29 | const hits: estypes.SearchHit>[] = []; 30 | const getRecursiveHits = async (after?: estypes.SearchSortResults | undefined) => { 31 | const response = await getExport({ 32 | size: 10000, 33 | body: { 34 | pit: { 35 | id: pitId, 36 | keep_alive: keepAlive, 37 | }, 38 | search_after: after, 39 | }, 40 | }).unwrap(); 41 | hits.push(...response.hits.hits); 42 | const length = response.hits.hits.length; 43 | if (length !== 0) { 44 | const searchAfter = response.hits.hits[length - 1].sort; 45 | await getRecursiveHits(searchAfter); 46 | } 47 | }; 48 | await getRecursiveHits(); 49 | 50 | const bookmarks: UrlAndTitle[] = []; 51 | hits.forEach((hit) => { 52 | if (hit._source) { 53 | const item: UrlAndTitle = { 54 | url: hit._source.url, 55 | title: hit._source.title, 56 | }; 57 | bookmarks.push(item); 58 | } 59 | }); 60 | 61 | // ! close pit 62 | await closePit({ 63 | body: { 64 | id: pitId, 65 | }, 66 | }); 67 | 68 | const html = makeNetscapeBookmarksHtml(bookmarks); 69 | const blob = new Blob([html], { type: 'text/html' }); 70 | const dateStr = formatDate(new Date(), 'yyyy-MM-dd'); 71 | const link = document.createElement('a'); 72 | link.href = URL.createObjectURL(blob); 73 | link.download = `researcher-bookmark-${dateStr}.html`; 74 | document.body.appendChild(link); 75 | link.click(); 76 | document.body.removeChild(link); 77 | } catch (e) { 78 | // TODO: shuold show global Alert with redux dispatch 79 | // console.log(e); 80 | } 81 | }; 82 | 83 | return ( 84 | 87 | ); 88 | } 89 | 90 | export default ExportButton; 91 | -------------------------------------------------------------------------------- /src/components/controls/ImportFromHtmlButton.tsx: -------------------------------------------------------------------------------- 1 | import { createRef } from 'preact'; 2 | 3 | import { FileCopy } from '@mui/icons-material'; 4 | import { Button } from '@mui/material'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | import { setImportResults } from 'src/redux/slices/importSlice'; 8 | import { useAppDispatch } from 'src/redux/store'; 9 | 10 | function ImportFromHtmlButton(): JSX.Element { 11 | const { t } = useTranslation(); 12 | const dispatch = useAppDispatch(); 13 | const uploadInputRef = createRef(); 14 | 15 | const handleHtmlImport = ( 16 | // event: JSXInternal.TargetedEvent, 17 | event: React.ChangeEvent, 18 | ) => { 19 | event.preventDefault(); 20 | if (event.currentTarget.files) { 21 | const file = event.currentTarget.files[0]; 22 | const reader = new FileReader(); 23 | reader.onload = async (e) => { 24 | if (e.target && e.target.result) { 25 | const text = e.target.result.toString(); 26 | const doc = new DOMParser().parseFromString(text, 'text/html'); 27 | const urls: string[] = []; 28 | doc.querySelectorAll('a').forEach((l) => { 29 | urls.push(l.href); 30 | }); 31 | dispatch(setImportResults(urls)); 32 | } 33 | }; 34 | reader.readAsText(file); 35 | } 36 | }; 37 | 38 | const handleOnClick = () => { 39 | uploadInputRef.current && uploadInputRef.current.click(); 40 | }; 41 | 42 | return ( 43 | <> 44 | handleHtmlImport(e)} 51 | /> 52 | 59 | 60 | ); 61 | } 62 | 63 | export default ImportFromHtmlButton; 64 | -------------------------------------------------------------------------------- /src/components/controls/LanguageSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useState, useRef } from 'react'; 3 | 4 | import { ExpandMore, Translate } from '@mui/icons-material'; 5 | import { Button, Menu, MenuItem, useMediaQuery, useTheme } from '@mui/material'; 6 | import { useTranslation } from 'react-i18next'; 7 | 8 | import TypographyText from 'src/components/atoms/TypographyText'; 9 | 10 | export default function LanguageSwitcher(): JSX.Element { 11 | const [anchorEl, setAnchorEl] = useState(null); 12 | const divRef = useRef(null); 13 | const theme = useTheme(); 14 | const isDownMD = useMediaQuery(theme.breakpoints.down('lg')); 15 | const { i18n } = useTranslation(); 16 | 17 | const currentLanguage = (): string => { 18 | const current = i18n.language; 19 | 20 | if (current.includes('ja')) { 21 | return '日本語'; 22 | } 23 | 24 | if (current.includes('en')) { 25 | return 'English'; 26 | } 27 | 28 | return ''; 29 | }; 30 | 31 | const handleClick = (event: React.MouseEvent) => { 32 | setAnchorEl(event.currentTarget); 33 | }; 34 | 35 | const handleClose = () => { 36 | setAnchorEl(null); 37 | }; 38 | 39 | const changeLanguage = (lang: string) => { 40 | i18n.changeLanguage(lang); 41 | handleClose(); 42 | }; 43 | 44 | return ( 45 |
46 | 55 | 62 | changeLanguage('ja')}>日本語 63 | changeLanguage('en')}>English 64 | 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/controls/MyRating.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { Rating } from '@mui/material'; 4 | 5 | import { useUpdateBookmarkMutation } from 'src/redux/services/elasticsearch/api'; 6 | import { updateSearchHit } from 'src/redux/slices/searchSlice'; 7 | import { useAppDispatch } from 'src/redux/store'; 8 | 9 | type Props = { 10 | id: string; 11 | index: string; 12 | stars: number; 13 | }; 14 | 15 | function MyRating({ id, index, stars }: Props): JSX.Element { 16 | const dispatch = useAppDispatch(); 17 | const [rating, setRating] = useState(stars || null); 18 | const [updateBookmark, {}] = useUpdateBookmarkMutation(); 19 | 20 | // ! setRating if stars prop changes 21 | useEffect(() => { 22 | setRating(stars); 23 | }, [stars]); 24 | 25 | const handleRating = (value: number | null) => { 26 | if (value) { 27 | updateBookmark({ id, index, body: { doc: { stars: value } } }); 28 | setRating(value); 29 | dispatch(updateSearchHit({ id, index, patch: { stars: value } })); 30 | } else { 31 | // ! set 0 if null is given 32 | updateBookmark({ id, index, body: { doc: { stars: 0 } } }); 33 | setRating(0); 34 | dispatch(updateSearchHit({ id, index, patch: { stars: 0 } })); 35 | } 36 | }; 37 | 38 | return handleRating(value)} />; 39 | } 40 | 41 | export default MyRating; 42 | -------------------------------------------------------------------------------- /src/components/controls/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import SearchIcon from '@mui/icons-material/Search'; 4 | import { IconButton, Paper, InputBase, Divider } from '@mui/material'; 5 | import { useHistory } from 'react-router-dom'; 6 | 7 | import { ROUTE_SEARCH } from 'src/constants'; 8 | import type { SearchMode } from 'src/types'; 9 | 10 | type Props = { 11 | autoFocus?: boolean; 12 | }; 13 | 14 | SearchBox.defaultProps = { 15 | autoFocus: false, 16 | }; 17 | 18 | function SearchBox({ autoFocus }: Props): JSX.Element { 19 | const history = useHistory(); 20 | const [keywords, setKeywords] = useState(''); 21 | 22 | function requestSubmit(e: React.FormEvent): void { 23 | e.preventDefault(); 24 | history.push(ROUTE_SEARCH, { keywords }); 25 | } 26 | 27 | return ( 28 | requestSubmit(e)}> 42 | setKeywords(e.target.value)} 53 | /> 54 | 55 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | export default SearchBox; 72 | -------------------------------------------------------------------------------- /src/components/controls/ThemeChangeIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { Brightness2, Brightness4 } from '@mui/icons-material'; 2 | import { IconButton } from '@mui/material'; 3 | 4 | import { toggleTheme } from 'src/redux/slices/settingSlice'; 5 | import { useAppDispatch, useAppSelector } from 'src/redux/store'; 6 | 7 | function ThemeChangeIconButton(): JSX.Element { 8 | const isDarkMode = useAppSelector((s) => s.settings.isDarkMode); 9 | const dispatch = useAppDispatch(); 10 | 11 | return ( 12 | dispatch(toggleTheme())} size="large"> 13 | {isDarkMode ? : } 14 | 15 | ); 16 | } 17 | 18 | export default ThemeChangeIconButton; 19 | -------------------------------------------------------------------------------- /src/components/dialogs/DeleteDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | } from '@mui/material'; 9 | import { useTranslation } from 'react-i18next'; 10 | 11 | import { useDeleteBookmarkMutation } from 'src/redux/services/elasticsearch/api'; 12 | import { closeDeleteDialog } from 'src/redux/slices/deleteDialogSlice'; 13 | import { useAppDispatch, useAppSelector } from 'src/redux/store'; 14 | 15 | type Props = { 16 | onOk: (id: string, index: string) => void; 17 | }; 18 | 19 | function DeleteDialog({ onOk }: Props): JSX.Element { 20 | const { t } = useTranslation(); 21 | const dispatch = useAppDispatch(); 22 | const { isOpen, id, index } = useAppSelector((s) => s.deleteDialog); 23 | const [deleteBookmark, {}] = useDeleteBookmarkMutation(); 24 | 25 | const handleClose = () => { 26 | dispatch(closeDeleteDialog()); 27 | }; 28 | 29 | const handleOk = async () => { 30 | if (id && index) { 31 | const response = await deleteBookmark({ id, index }).unwrap(); 32 | if (response.result === 'deleted') { 33 | dispatch(closeDeleteDialog()); 34 | onOk(id, index); 35 | } 36 | } 37 | }; 38 | 39 | return ( 40 | 45 | {t('Delete Bookmark')} 46 | 47 | 48 | {t('Are you sure?')} 49 | 50 | 51 | 52 | 55 | 58 | 59 | 60 | ); 61 | } 62 | 63 | export default DeleteDialog; 64 | -------------------------------------------------------------------------------- /src/components/drawers/BrandMenuLogo.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from '@mui/icons-material'; 2 | import { Box, IconButton, Button, Typography } from '@mui/material'; 3 | import { grey } from '@mui/material/colors'; 4 | import { useHistory } from 'react-router-dom'; 5 | 6 | import FlexBox from 'src/components/atoms/FlexBox'; 7 | import { APP_NAME, ROUTE_HOME } from 'src/constants'; 8 | 9 | type Props = { 10 | onMenuIconClick: () => void; 11 | }; 12 | 13 | function BrandMenuLogo({ onMenuIconClick }: Props): JSX.Element { 14 | const history = useHistory(); 15 | 16 | return ( 17 | 18 | onMenuIconClick()} size="large"> 19 | 20 | 21 | 22 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default BrandMenuLogo; 44 | -------------------------------------------------------------------------------- /src/components/drawers/LeftDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { 4 | ExpandLess, 5 | ExpandMore, 6 | Home, 7 | ImportExport, 8 | PushPin, 9 | Settings, 10 | StarBorder, 11 | VideoLibrary, 12 | } from '@mui/icons-material'; 13 | import { 14 | Box, 15 | Collapse, 16 | Drawer, 17 | List, 18 | ListItemButton, 19 | ListItemIcon, 20 | ListItemText, 21 | Rating, 22 | } from '@mui/material'; 23 | import { useTranslation } from 'react-i18next'; 24 | import { useHistory } from 'react-router-dom'; 25 | 26 | import BrandMenuLogo from 'src/components/drawers/BrandMenuLogo'; 27 | import IconList from 'src/components/drawers/list_group/IconList'; 28 | import { 29 | LEFT_DRAWER_ICON_WIDTH, 30 | LEFT_DRAWER_WIDTH, 31 | NAVBAR_HEIGHT, 32 | ROUTE_HOME, 33 | ROUTE_IMPORT, 34 | ROUTE_SEARCH, 35 | ROUTE_SETTINGS, 36 | VIDEO_SITES, 37 | } from 'src/constants'; 38 | import type { SearchMode } from 'src/types'; 39 | 40 | type Props = { 41 | open: boolean; 42 | variant: 'persistent' | 'permanent' | 'temporary'; 43 | onMenuIconClick: () => void; 44 | displayBrandHead: boolean; 45 | onClose: () => void; 46 | }; 47 | 48 | function LeftDrawer({ 49 | open, 50 | displayBrandHead, 51 | variant, 52 | onMenuIconClick, 53 | onClose, 54 | }: Props): JSX.Element { 55 | const { t } = useTranslation(); 56 | const history = useHistory(); 57 | const [openStars, setOpenStars] = useState(true); 58 | 59 | const handleToggleStarMenu = () => { 60 | setOpenStars(!openStars); 61 | }; 62 | 63 | const goToSearchByStars = (value: number) => { 64 | history.push(ROUTE_SEARCH, { stars: value }); 65 | }; 66 | 67 | return ( 68 | <> 69 | onClose()}> 75 | {displayBrandHead && ( 76 | 81 | 82 | 83 | )} 84 | 89 | {!displayBrandHead && } 90 | 91 | 92 | } 95 | onClick={() => history.push(ROUTE_HOME)} 96 | /> 97 | 98 | } 101 | onClick={() => history.push(ROUTE_SEARCH, { isReadLater: true })} 102 | /> 103 | 104 | 105 | 106 | 107 | 108 | 109 | {openStars ? : } 110 | 111 | 112 | 113 | {[5, 4, 3, 2, 1, 0].map((v, i) => { 114 | return ( 115 | goToSearchByStars(v)}> 119 | 120 | 121 | ); 122 | })} 123 | 124 | 125 | 126 | } 129 | onClick={() => history.push(ROUTE_SEARCH, { sites: VIDEO_SITES })} 130 | /> 131 | 132 | } 135 | onClick={() => history.push(ROUTE_IMPORT)} 136 | /> 137 | 138 | } 141 | onClick={() => history.push(ROUTE_SETTINGS)} 142 | /> 143 | 144 | 145 | 146 | 147 | ); 148 | } 149 | 150 | export default LeftDrawer; 151 | -------------------------------------------------------------------------------- /src/components/drawers/SmallSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Home, 3 | ImportExport, 4 | PushPin, 5 | Settings, 6 | StarBorder, 7 | VideoLibrary, 8 | } from '@mui/icons-material'; 9 | import { Container } from '@mui/material'; 10 | import { useTranslation } from 'react-i18next'; 11 | import { useHistory } from 'react-router-dom'; 12 | 13 | import SmallSidebarItem from './SmallSidebarItem'; 14 | 15 | import { 16 | LEFT_DRAWER_SECONDARY_WIDTH, 17 | ROUTE_HOME, 18 | ROUTE_IMPORT, 19 | ROUTE_SEARCH, 20 | ROUTE_SETTINGS, 21 | VIDEO_SITES, 22 | } from 'src/constants'; 23 | import type { SearchMode } from 'src/types'; 24 | 25 | type Props = { 26 | open: boolean; 27 | }; 28 | 29 | function SmallSidebar({ open }: Props): JSX.Element { 30 | const { t } = useTranslation(); 31 | const history = useHistory(); 32 | 33 | if (open) { 34 | return ( 35 | 44 | } 46 | text={t('Home')} 47 | onClick={() => history.push(ROUTE_HOME)} 48 | /> 49 | } 51 | text={t('Read Later')} 52 | onClick={() => history.push(ROUTE_SEARCH, { isReadLater: true })} 53 | /> 54 | } 56 | text={t('Stars')} 57 | onClick={() => history.push(ROUTE_SEARCH, { stars: 5 })} 58 | /> 59 | } 61 | text={t('Videos')} 62 | onClick={() => history.push(ROUTE_SEARCH, { sites: VIDEO_SITES })} 63 | /> 64 | } 66 | text={t('Import')} 67 | onClick={() => history.push(ROUTE_IMPORT)} 68 | /> 69 | } 71 | text={t('Settings')} 72 | onClick={() => history.push(ROUTE_SETTINGS)} 73 | /> 74 | 75 | ); 76 | } 77 | 78 | return ; 79 | } 80 | 81 | export default SmallSidebar; 82 | -------------------------------------------------------------------------------- /src/components/drawers/SmallSidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from '@mui/material'; 2 | import { grey } from '@mui/material/colors'; 3 | 4 | type Props = { 5 | icon: JSX.Element; 6 | text: string; 7 | onClick: () => void; 8 | }; 9 | 10 | function SmallSidebarItem({ icon, text, onClick }: Props): JSX.Element { 11 | return ( 12 | 43 | ); 44 | } 45 | 46 | export default SmallSidebarItem; 47 | -------------------------------------------------------------------------------- /src/components/drawers/list_group/IconList.tsx: -------------------------------------------------------------------------------- 1 | import { ListItem, ListItemIcon } from '@mui/material'; 2 | 3 | import TypographyText from 'src/components/atoms/TypographyText'; 4 | import { LEFT_DRAWER_ICON_WIDTH } from 'src/constants'; 5 | 6 | type Props = { 7 | headerText: string; 8 | icon: JSX.Element; 9 | onClick: () => void; 10 | }; 11 | 12 | function IconList({ headerText, icon, onClick }: Props): JSX.Element { 13 | function handleClick() { 14 | onClick(); 15 | } 16 | 17 | return ( 18 | 26 | {icon} 27 | 28 | 29 | ); 30 | } 31 | 32 | export default IconList; 33 | -------------------------------------------------------------------------------- /src/components/import/BrowserBookmarksTreeView.tsx: -------------------------------------------------------------------------------- 1 | import { TreeView as MuiTreeView } from '@mui/lab'; 2 | import { useEffectOnce } from 'react-use'; 3 | import browser from 'webextension-polyfill'; 4 | 5 | import MyTreeItem from 'src/components/import/MyTreeItem'; 6 | import { setBrowserBookmarks } from 'src/redux/slices/importSlice'; 7 | import { useAppDispatch, useAppSelector } from 'src/redux/store'; 8 | 9 | function BrowserBookmarksTreeView(): JSX.Element { 10 | const dispatch = useAppDispatch(); 11 | const browserBookmarks = useAppSelector((s) => s.import.browserBookmarks); 12 | 13 | useEffectOnce(() => { 14 | const getBookmarks = async () => { 15 | const bookmarks = await browser.bookmarks.getTree(); 16 | dispatch(setBrowserBookmarks(bookmarks[0])); 17 | }; 18 | if (browserBookmarks == null) { 19 | getBookmarks(); 20 | } 21 | }); 22 | 23 | const handleSelect = (event: React.ChangeEvent, _nodeId: string) => { 24 | event.preventDefault(); 25 | }; 26 | 27 | if (browserBookmarks == null) { 28 | return <>; 29 | } 30 | 31 | return ( 32 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export default BrowserBookmarksTreeView; 51 | -------------------------------------------------------------------------------- /src/components/import/MyTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | import { ChevronRight, ExpandMore } from '@mui/icons-material'; 4 | import type { TreeItemContentProps, TreeItemProps } from '@mui/lab'; 5 | import { TreeItem, useTreeItem } from '@mui/lab'; 6 | import { Typography } from '@mui/material'; 7 | import clsx from 'clsx'; 8 | 9 | import FlexBox from 'src/components/atoms/FlexBox'; 10 | import TreeItemLabel from 'src/components/import/TreeItemLabel'; 11 | import { toggleNodeChecked } from 'src/redux/slices/importSlice'; 12 | import { useAppDispatch } from 'src/redux/store'; 13 | import type { BrowserBookmarksType } from 'src/types'; 14 | 15 | const CustomContent = forwardRef((props: TreeItemContentProps, ref) => { 16 | const { 17 | classes, 18 | className, 19 | label, 20 | nodeId, 21 | icon: iconProp, 22 | expansionIcon, 23 | displayIcon, 24 | } = props; 25 | 26 | const { 27 | disabled, 28 | expanded, 29 | selected, 30 | focused, 31 | handleExpansion, 32 | handleSelection, 33 | preventSelection, 34 | } = useTreeItem(nodeId); 35 | 36 | const icon = iconProp || expansionIcon || displayIcon; 37 | 38 | const dispatch = useAppDispatch(); 39 | 40 | const handleMouseDown = (event: React.MouseEvent) => { 41 | preventSelection(event); 42 | }; 43 | 44 | const handleExpansionClick = (event: React.MouseEvent) => { 45 | handleExpansion(event); 46 | }; 47 | 48 | const handleSelectionClick = (event: React.MouseEvent) => { 49 | handleSelection(event); 50 | dispatch(toggleNodeChecked(nodeId)); 51 | }; 52 | 53 | return ( 54 |
}> 63 |
64 | {icon} 65 |
66 | 70 | {label} 71 | 72 |
73 | ); 74 | }); 75 | 76 | const CustomTreeItem = (props: TreeItemProps) => ( 77 | 78 | ); 79 | 80 | type Props = { 81 | node: BrowserBookmarksType; 82 | }; 83 | 84 | const MyTreeItem = ({ node }: Props): JSX.Element => { 85 | const { id, type } = node; 86 | 87 | if (id == null || type == null) { 88 | return <>; 89 | } 90 | 91 | return ( 92 | 97 | {type === 'folder' && } 98 | 99 | } 100 | expandIcon={ 101 | 102 | {type === 'folder' && } 103 | 104 | } 105 | label={}> 106 | {Array.isArray(node.children) ? ( 107 | node.children.map((node) => { 108 | return ; 109 | }) 110 | ) : ( 111 | <> 112 | )} 113 | 114 | ); 115 | }; 116 | 117 | export default MyTreeItem; 118 | -------------------------------------------------------------------------------- /src/components/import/TextAreaDialog.tsx: -------------------------------------------------------------------------------- 1 | import { createRef } from 'preact'; 2 | 3 | import { 4 | Button, 5 | Dialog, 6 | DialogActions, 7 | DialogContent, 8 | DialogTitle, 9 | TextareaAutosize, 10 | } from '@mui/material'; 11 | import { useTranslation } from 'react-i18next'; 12 | 13 | import SpacerDivider from 'src/components/atoms/SpacerDivider'; 14 | import { setImportResultsFromText } from 'src/redux/slices/importSlice'; 15 | import { useAppDispatch } from 'src/redux/store'; 16 | 17 | type Props = { 18 | open: boolean; 19 | onClose: () => void; 20 | }; 21 | 22 | function TextAreaDialog({ open, onClose }: Props): JSX.Element { 23 | const { t } = useTranslation(); 24 | const ref = createRef(); 25 | const dispatch = useAppDispatch(); 26 | 27 | const handleOK = () => { 28 | if (ref.current && ref.current.value !== '') { 29 | dispatch(setImportResultsFromText(ref.current.value)); 30 | } 31 | onClose(); 32 | }; 33 | 34 | return ( 35 | onClose()} fullWidth maxWidth="xl"> 36 | {t('List of URLs to import')} 37 | 38 | 39 | 44 | 45 | 46 | 47 | 50 | 51 | 52 | ); 53 | } 54 | 55 | export default TextAreaDialog; 56 | -------------------------------------------------------------------------------- /src/components/import/TreeItemLabel.tsx: -------------------------------------------------------------------------------- 1 | import { FolderOpenSharp } from '@mui/icons-material'; 2 | import { Box, Checkbox, Link } from '@mui/material'; 3 | 4 | import FlexBox from 'src/components/atoms/FlexBox'; 5 | import LineClamp from 'src/components/atoms/LineClamp'; 6 | import { isWebUrl } from 'src/libs/utils'; 7 | import type { BrowserBookmarksType } from 'src/types'; 8 | 9 | type Props = { 10 | node: BrowserBookmarksType; 11 | }; 12 | 13 | const TreeItemLabel = ({ node }: Props): JSX.Element => { 14 | const { url, type } = node; 15 | const title = node.parentId ? node.title : 'Browser Bookmarks'; 16 | 17 | if (url && !isWebUrl(url)) { 18 | return <>; 19 | } 20 | 21 | if (type === 'folder') { 22 | return ( 23 | 27 | {node.children && node.children.length > 0 && ( 28 | 29 | 35 | 36 | )} 37 | 38 | 39 | 40 | 41 | {title === '' ? 'No Name' : title} 42 | 43 | 44 | ); 45 | } 46 | 47 | return ( 48 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | {title === '' ? 'No Name' : title} 59 | 60 | 61 | 62 | 63 | 69 | {url} 70 | 71 | 72 | 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default TreeItemLabel; 79 | -------------------------------------------------------------------------------- /src/components/import/TreeViewDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import SpacerDivider from 'src/components/atoms/SpacerDivider'; 5 | import BrowserBookmarksTreeView from 'src/components/import/BrowserBookmarksTreeView'; 6 | 7 | type Props = { 8 | open: boolean; 9 | onClose: () => void; 10 | }; 11 | 12 | function TreeViewDialog({ open, onClose }: Props): JSX.Element { 13 | const { t } = useTranslation(); 14 | 15 | return ( 16 | 17 | {t('Check bookmarks you want to import')} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export default TreeViewDialog; 33 | -------------------------------------------------------------------------------- /src/components/layout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import { CssBaseline, useMediaQuery, useTheme, Box } from '@mui/material'; 4 | 5 | import LeftDrawer from 'src/components/drawers/LeftDrawer'; 6 | import SmallSidebar from 'src/components/drawers/SmallSidebar'; 7 | import NavBar from 'src/components/layout/NavBar'; 8 | import { 9 | LEFT_DRAWER_SECONDARY_WIDTH, 10 | LEFT_DRAWER_WIDTH, 11 | NAVBAR_HEIGHT, 12 | } from 'src/constants'; 13 | import useAutoToggleDrawer from 'src/hooks/useAutoToggleDrawer'; 14 | import { 15 | closeOverlayDrawer, 16 | toggleLeftDrawerPrimary, 17 | toggleOverlayDrawer, 18 | } from 'src/redux/slices/drawerSlice'; 19 | import { useAppDispatch } from 'src/redux/store'; 20 | 21 | type Props = { 22 | children: React.ReactFragment; 23 | }; 24 | 25 | function AppLayout({ children }: Props): JSX.Element { 26 | const theme = useTheme(); 27 | const dispatch = useAppDispatch(); 28 | const firstBoundary = useMediaQuery(theme.breakpoints.up('sm')); 29 | const secondBoundary = useMediaQuery(theme.breakpoints.up('lg')); 30 | const { isLeftDrawerPrimaryOpen, isLeftDrawerSecondaryOpen, isOverlayDrawerOpen } = 31 | useAutoToggleDrawer(firstBoundary, secondBoundary); 32 | const leftDrawerWidth = useMemo(() => { 33 | if (isLeftDrawerPrimaryOpen) { 34 | return LEFT_DRAWER_WIDTH; 35 | } else if (!isLeftDrawerPrimaryOpen && isLeftDrawerSecondaryOpen) { 36 | return LEFT_DRAWER_SECONDARY_WIDTH; 37 | } 38 | return '0px'; 39 | }, [isLeftDrawerPrimaryOpen, isLeftDrawerSecondaryOpen]); 40 | 41 | function toggleDrawer() { 42 | if (secondBoundary) { 43 | dispatch(toggleLeftDrawerPrimary()); 44 | } else { 45 | dispatch(toggleOverlayDrawer()); 46 | } 47 | } 48 | 49 | return ( 50 | <> 51 | 52 |
53 |
54 | 55 |
56 | 57 | 69 | null} 75 | /> 76 | 77 | 78 | 79 | 80 | 87 |
{children}
88 |
89 | 90 | dispatch(closeOverlayDrawer())} 95 | onMenuIconClick={toggleDrawer} 96 | /> 97 |
98 | 99 | ); 100 | } 101 | 102 | export default AppLayout; 103 | -------------------------------------------------------------------------------- /src/components/layout/BrandMenuLogo.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from '@mui/icons-material'; 2 | import { Box, IconButton } from '@mui/material'; 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | import LogoSmall from './LogoSmall'; 6 | 7 | import { ROUTE_HOME } from 'src/constants'; 8 | 9 | type Props = { 10 | onMenuIconClick: () => void; 11 | }; 12 | 13 | function BrandMenuLogo({ onMenuIconClick }: Props): JSX.Element { 14 | const history = useHistory(); 15 | 16 | return ( 17 | 18 | onMenuIconClick()} size="large"> 19 | 20 | 21 | 22 | history.push(ROUTE_HOME)} /> 23 | 24 | 25 | ); 26 | } 27 | 28 | export default BrandMenuLogo; 29 | -------------------------------------------------------------------------------- /src/components/layout/LogoSmall.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from '@mui/material'; 2 | import { grey } from '@mui/material/colors'; 3 | 4 | import FlexBox from '../atoms/FlexBox'; 5 | 6 | import { APP_NAME } from 'src/constants'; 7 | 8 | type Props = { 9 | onClick: () => void; 10 | }; 11 | 12 | function LogoSmall({ onClick }: Props): JSX.Element { 13 | return ( 14 | 31 | ); 32 | } 33 | 34 | export default LogoSmall; 35 | -------------------------------------------------------------------------------- /src/components/layout/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowBack, ArrowForward, Search } from '@mui/icons-material'; 2 | import { Box, IconButton, useTheme, useMediaQuery } from '@mui/material'; 3 | import { useHistory } from 'react-router-dom'; 4 | import useToggle from 'react-use/lib/useToggle'; 5 | 6 | import FlexBox from 'src/components/atoms/FlexBox'; 7 | import LanguageSwitcher from 'src/components/controls/LanguageSwitcher'; 8 | import SearchBox from 'src/components/controls/SearchBox'; 9 | import ThemeChangeIconButton from 'src/components/controls/ThemeChangeIconButton'; 10 | import BrandMenuLogo from 'src/components/layout/BrandMenuLogo'; 11 | import { NAVBAR_HEIGHT } from 'src/constants'; 12 | 13 | type Props = { 14 | onMenuIconClick: () => void; 15 | }; 16 | 17 | function NavBar({ onMenuIconClick }: Props): JSX.Element { 18 | const theme = useTheme(); 19 | const isDownSM = useMediaQuery(theme.breakpoints.down('md')); 20 | const [isSearchFieldOpen, toggleSearchField] = useToggle(false); 21 | const history = useHistory(); 22 | 23 | if (isSearchFieldOpen && isDownSM) { 24 | return ( 25 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | return ( 58 | 71 | 75 | 76 | 77 | 78 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | toggleSearchField()} size="large"> 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | history.goBack()} /> 102 | 103 | 104 | 105 | history.goForward()} /> 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | ); 120 | } 121 | 122 | export default NavBar; 123 | -------------------------------------------------------------------------------- /src/components/popup/AlertBox.tsx: -------------------------------------------------------------------------------- 1 | import { Launch } from '@mui/icons-material'; 2 | import { Alert, AlertTitle, Button } from '@mui/material'; 3 | 4 | import FlexBox from 'src/components/atoms/FlexBox'; 5 | import { openApp } from 'src/libs/browsers'; 6 | 7 | type Props = { 8 | title: string; 9 | buttonText: string; 10 | }; 11 | 12 | function AlertBox({ title, buttonText }: Props): JSX.Element { 13 | return ( 14 | 15 | 16 | {title} 17 | 18 | 19 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default AlertBox; 28 | -------------------------------------------------------------------------------- /src/components/popup/HealthCheck.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Alert, AlertTitle } from '@mui/material'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import FlexBox from 'src/components/atoms/FlexBox'; 5 | import { useGetHealthCheckQuery } from 'src/redux/services/elasticsearch/api'; 6 | 7 | const HealthCheck = (): JSX.Element => { 8 | const { t } = useTranslation(); 9 | const { error, isLoading } = useGetHealthCheckQuery({}); 10 | 11 | if (isLoading) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | return ( 20 | <> 21 | {error && ( 22 | 23 | {t('error occurred')} 24 | {t('general error')} 25 | 26 | )} 27 | 28 | ); 29 | }; 30 | 31 | export default HealthCheck; 32 | -------------------------------------------------------------------------------- /src/components/popup/PopupAppBar.tsx: -------------------------------------------------------------------------------- 1 | import { Launch } from '@mui/icons-material'; 2 | import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'; 3 | 4 | import { APP_NAME } from 'src/constants'; 5 | import { openApp } from 'src/libs/browsers'; 6 | 7 | function PopupAppbar(): JSX.Element { 8 | return ( 9 | 10 | 11 | 12 | 13 | {APP_NAME} 14 | 15 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default PopupAppbar; 25 | -------------------------------------------------------------------------------- /src/components/popup/PopupEsCheck.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from '@mui/material'; 2 | import { useTranslation } from 'react-i18next'; 3 | import { useEffectOnce } from 'react-use'; 4 | 5 | import FlexBox from 'src/components/atoms/FlexBox'; 6 | import AlertBox from 'src/components/popup/AlertBox'; 7 | import { validateElasticsearch } from 'src/redux/slices/esConfigSlice'; 8 | import { useAppDispatch, useAppSelector } from 'src/redux/store'; 9 | 10 | type Props = { 11 | children: React.ReactElement; 12 | }; 13 | 14 | function PopupEsCheck({ children }: Props): JSX.Element { 15 | const { t } = useTranslation(); 16 | const dispatch = useAppDispatch(); 17 | const { isReady, isRequiredSetup, isRequiredMigration } = useAppSelector( 18 | (s) => s.esconfig, 19 | ); 20 | 21 | useEffectOnce(() => { 22 | dispatch(validateElasticsearch()); 23 | }); 24 | 25 | if (isReady) { 26 | return children; 27 | } 28 | 29 | if (isRequiredSetup) { 30 | return ( 31 | 35 | ); 36 | } 37 | 38 | if (isRequiredMigration) { 39 | return ( 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | export default PopupEsCheck; 52 | -------------------------------------------------------------------------------- /src/components/readable/EmbedPlayer.tsx: -------------------------------------------------------------------------------- 1 | import type { EmbeddableType } from 'src/types'; 2 | 3 | type Props = { 4 | data: EmbeddableType; 5 | }; 6 | 7 | const EmbedPlayer = ({ data }: Props): JSX.Element => { 8 | const { isEmbeddable, provider, identifier } = data; 9 | if (isEmbeddable) { 10 | if (provider && identifier) { 11 | if (provider === 'youtube') { 12 | return ( 13 |
14 |