{JSON.stringify(error, null, 2)}20 |
and from innerHTML for HilightJS
30 | if (node.tagName === 'PRE' || node.tagName === 'CODE') {
31 | if (node.textContent) {
32 | // ! use textContent as innerHTML and make sure it's escaped
33 | node.innerHTML = htmlEscape(node.textContent);
34 | }
35 | }
36 |
37 | // ! remove empty node without image tag
38 | if (!node.hasChildNodes() && !node.textContent && node.tagName !== 'IMG') {
39 | node.remove();
40 | }
41 |
42 | // ! remove node if textContent only has \s+
43 | if (node.textContent) {
44 | const regex = new RegExp(/^\s+$/);
45 | if (regex.test(node.textContent)) {
46 | node.remove();
47 | }
48 | }
49 | });
50 |
51 | export default myDOMPurify;
52 |
--------------------------------------------------------------------------------
/src/libs/elasticsearch.ts:
--------------------------------------------------------------------------------
1 | import type { SearchTotalHits } from '@elastic/elasticsearch/api/types';
2 |
3 | export const getSearchTotalHits = (totalHits: number | SearchTotalHits): number => {
4 | if (typeof totalHits === 'number') {
5 | return totalHits;
6 | } else if (typeof totalHits === 'object' && 'value' in totalHits) {
7 | return totalHits.value;
8 | }
9 | return 0;
10 | };
11 |
--------------------------------------------------------------------------------
/src/libs/highlightjs.ts:
--------------------------------------------------------------------------------
1 | import hljs from 'highlight.js/lib/common';
2 | // import css from 'highlight.js/lib/languages/css';
3 | // import javascript from 'highlight.js/lib/languages/javascript';
4 | // import json from 'highlight.js/lib/languages/json';
5 | // import xml from 'highlight.js/lib/languages/xml';
6 |
7 | const HighlightJs = hljs;
8 |
9 | // HighlightJs.configure({ ignoreUnescapedHTML: true });
10 | // HighlightJs.registerLanguage('javascript', javascript);
11 | // HighlightJs.registerLanguage('css', css);
12 | // HighlightJs.registerLanguage('xml', xml);
13 | // HighlightJs.registerLanguage('json', json);
14 |
15 | export default HighlightJs;
16 |
--------------------------------------------------------------------------------
/src/libs/plimit.ts:
--------------------------------------------------------------------------------
1 | import pLimit from 'p-limit';
2 |
3 | export const limit1 = pLimit(1);
4 | export const limit3 = pLimit(3);
5 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home": "Home",
3 | "Stars": "Stars",
4 | "Videos": "Videos",
5 | "Settings": "Settings",
6 | "Import": "Import",
7 | "Import bookmarks": "Import bookmarks",
8 | "Export": "Export",
9 | "Open App": "Open re:searcher",
10 | "Bookmark": "Bookmark",
11 | "Delete": "Delete",
12 | "Update": "Update",
13 | "Upgrade": "Upgrade",
14 | "Close": "Close",
15 | "error occurred": "Error occurred!",
16 | "general error": "Your ElasticSearch server isn't started or hasn't been setup yet?",
17 | "View": "View",
18 | "Pin": "Pin",
19 | "Pinned": "Pinned",
20 | "URL has hash": "URL has hash query. Do you want to remove it to prevent duplicate records?",
21 | "Yes": "Yes",
22 | "No": "No",
23 | "OK": "OK",
24 | "Already Bookmarked": "Already Bookmarked",
25 | "Successfully Bookmarked": "Successfully Bookmarked",
26 | "Successfully updated": "Successfully updated",
27 | "Successfully deleted": "Successfully deleted",
28 | "Cannot bookmark": "You can't bookmark this page bacause it's not a web page.",
29 | "Choose bookmarks from browser": "Choose bookmarks from browser",
30 | "From Textarea": "From Textarea",
31 | "List of URLs to import": "List of URLs to import",
32 | "Enter URLs on each line": "Enter URLs on each line...",
33 | "Check bookmarks you want to import": "Check bookmarks you want to import",
34 | "Waiting for fetching": "Waiting for fetching...",
35 | "Successfully bookmarked": "Successfully bookmarked!",
36 | "Failed to create index": "Failed to create index.",
37 | "Cant fetch content": "Can't fetch content: probably access rejected based on security reasons.",
38 | "Cant create bookmark": "Can't create bookmark.",
39 | "Start fetching": "Start fetching",
40 | "Import from HTML": "Import from Bookmark File (HTML file)",
41 | "Read Later": "Read Later",
42 | "Welcome to RE:SEARCHER": "Welcome to RE:SEARCHER",
43 | "RE:SEARCHER is a personal search engine for your bookmarks.": "RE:SEARCHER is a personal search engine for your bookmarks.",
44 | "Has been setup?": "Has been setup?",
45 | "This page can be shown if you do not start Elasticsearch.": "This page can be shown when you do not start Elasticsearch or change Elasticsearch url/port.",
46 | "Please make sure your Elasticsearch is started if you already have done this step.": "Please make sure your Elasticsearch is started and update url/port if you already have done this step.",
47 | "How to Set Up": "How to Set Up",
48 | "Please see a document": "Please see a document",
49 | "Create Indices": "Create Indices",
50 | "Click the button to create indices.": "Click the button to create indices.",
51 | "Create Indices for RE:SEARCHER": "Create Indices for RE:SEARCHER",
52 | "Elasticsearch URL": "Elasticsearch URL",
53 | "Please update url if you want to change Elasticsearch url and port.": "Please update url if you want to change Elasticsearch url and port.",
54 | "Export bookmarks to HTML.": "Export bookmarks to HTML. You can import this file into web browsers.",
55 | "- Loading -": "- Loading -",
56 | "- End -": "- End -",
57 | "Nothing found.": "Nothing found.",
58 | "Click the button bellow to execute upgrading script.": "Click the button bellow to execute upgrading script.",
59 | "Upgrade to new version": "Upgrade to new version",
60 | "Please upgrade to a newer version.": "Please upgrade to a newer version.",
61 | "Backup": "Backup",
62 | "Backup bookmarks to HTML.": "It is recommended that you keep a backup of all your bookmarks as HTML before upgrading!"
63 | }
64 |
--------------------------------------------------------------------------------
/src/locales/ja.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home": "ホーム",
3 | "Stars": "スター",
4 | "Videos": "動画",
5 | "Settings": "設定",
6 | "Import": "インポート",
7 | "Import bookmarks": "ブックマークのインポート",
8 | "Export": "エクスポート",
9 | "Open App": "re:searcherを開く",
10 | "Bookmark": "ブックマーク",
11 | "Delete": "削除",
12 | "Update": "更新",
13 | "Upgrade": "アップグレード",
14 | "Close": "閉じる",
15 | "error occurred": "エラーが発生しました",
16 | "general error": "ElasticSearchが起動されていないか、まだセットアップされていないようです。",
17 | "View": "View",
18 | "Pin": "Pin",
19 | "Pinned": "Pinned",
20 | "URL has hash": "URLに#タグがあります。重複レコードを避けるために除去しますか?",
21 | "Yes": "はい",
22 | "No": "いいえ",
23 | "OK": "OK",
24 | "Already Bookmarked": "既にブックマークしています。",
25 | "Successfully Bookmarked": "ブックマークしました。",
26 | "Successfully updated": "ブックマークを更新しました。",
27 | "Successfully deleted": "ブックマークを削除しました。",
28 | "Cannot bookmark": "このページはウェブページでないのでブックマークできません。",
29 | "Choose bookmarks from browser": "ブラウザからブックマークを選ぶ",
30 | "From Textarea": "テキストエリアから",
31 | "List of URLs to import": "インポートするURLの一覧",
32 | "Enter URLs on each line": "各行にURLを入力してください・・・",
33 | "Check bookmarks you want to import": "インポートしたいブックマークにチェックを入れてください",
34 | "Waiting for fetching": "ページの取得待ちです...",
35 | "Successfully bookmarked": "ブックマークが完了しました。",
36 | "Failed to create index": "インデックスの作成に失敗しました。",
37 | "Cant fetch content": "セキュリティ事由によりコンテンツへのアクセスを拒否されました。",
38 | "Cant create bookmark": "ブックマークの作成に失敗しました。",
39 | "Start fetching": "インポートを開始",
40 | "Import from HTML": "バックアップHTMLファイルからインポート",
41 | "Read Later": "後で読む",
42 | "Welcome to RE:SEARCHER": "RE:SEARCHERへようこそ",
43 | "RE:SEARCHER is a personal search engine for your bookmarks.": "RE:SEARCHERはブックマークを全文検索できるブラウザ拡張です。",
44 | "Has been setup?": "既にセットアップはお済みですか?",
45 | "This page can be shown if you do not start Elasticsearch.": "このページはElasticsearchを起動していない場合やURL・ポート番号を変更した場合も表示されます。",
46 | "Please make sure your Elasticsearch is started if you already have done this step.": "既にセットアップを済ませている場合は、URLとポート番号を正しく設定した後、Elasticsearchが起動した状態でリロードしてみてください。",
47 | "How to Set Up": "セットアップ手順",
48 | "Please see a document": "下記のドキュメントを参照してください。",
49 | "Create Indices": "インデックスの作成",
50 | "Click the button to create indices.": "下のボタンをクリックしてインデックスを作成してください。",
51 | "Create Indices for RE:SEARCHER": "RE:SEARCHERのインデックスを作成",
52 | "Elasticsearch URL": "Elasticsearch URL",
53 | "Please update url if you want to change Elasticsearch url and port.": "ElasticsearchのURLとポートを変更したい場合は、設定してください。",
54 | "Export bookmarks to HTML.": "ブックマークをHTMLファイルにエクスポートできます。このファイルはブラウザにインポートすることも可能です。",
55 | "- Loading -": "- 読み込み中 -",
56 | "- End -": "- End -",
57 | "Nothing found.": "何も見つかりませんでした。",
58 | "Click the button bellow to execute upgrading script.": "アップグレードを行うために下のボタンをクリックしてください。",
59 | "Upgrade to new version": "新しいバージョンへアップグレード",
60 | "Please upgrade to a newer version.": "新しいバージョンへアップグレードする必要があります。",
61 | "Backup": "バックアップ",
62 | "Backup bookmarks to HTML.": "アップグレードの前に全てのブックマークをHTML型式でバックアップすることを推奨します。"
63 | }
64 |
--------------------------------------------------------------------------------
/src/manifest.ts:
--------------------------------------------------------------------------------
1 | import type { Manifest } from 'webextension-polyfill';
2 | import pkg from '../package.json';
3 | import { isDev, port } from '../scripts/utils';
4 |
5 | export async function getManifest(): Promise {
6 | return {
7 | manifest_version: 2,
8 | name: pkg.displayName || pkg.name,
9 | version: pkg.version,
10 | description: pkg.description,
11 | browser_action: {
12 | default_icon: {
13 | 16: './public/icon-16.png',
14 | 32: './public/icon-32.png',
15 | 48: './public/icon-48.png',
16 | 96: './public/icon-96.png',
17 | 128: './public/icon-128.png',
18 | 512: './public/icon-512.png',
19 | },
20 | default_popup: './dist/views/popup/index.html',
21 | },
22 | options_ui: {
23 | page: './dist/views/app/index.html',
24 | open_in_tab: true,
25 | chrome_style: false,
26 | },
27 | content_scripts: [
28 | {
29 | matches: ['http://*/*', 'https://*/*'],
30 | js: ['./dist/content_scripts.global.js'],
31 | },
32 | ],
33 | icons: {
34 | 16: './public/icon-16.png',
35 | 32: './public/icon-32.png',
36 | 48: './public/icon-48.png',
37 | 96: './public/icon-96.png',
38 | 128: './public/icon-128.png',
39 | 512: './public/icon-512.png',
40 | },
41 | permissions: [
42 | 'tabs',
43 | 'activeTab',
44 | 'storage',
45 | 'bookmarks',
46 | 'http://*/*',
47 | 'https://*/*',
48 | ],
49 | // ! new tab
50 | // chrome_url_overrides: {
51 | // newtab: './dist/new-tab/index.html',
52 | // },
53 | // ! background
54 | // background: {
55 | // page: './dist/views/background/index.html',
56 | // scripts: ['./dist/background.global.js'],
57 | // persistent: true, // ! false gives firefox warning (not supported)
58 | // },
59 | // this is required on dev for Vite script to load
60 | content_security_policy: isDev
61 | ? `script-src \'self\' http://localhost:${port}; object-src \'self\'`
62 | : undefined,
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/src/pages/ImportPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { FeaturedPlayListOutlined, PlaylistAdd } from '@mui/icons-material';
4 | import { Backdrop, Button, CircularProgress, Container } from '@mui/material';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | import FlexBox from 'src/components/atoms/FlexBox';
8 | import SpacerDivider from 'src/components/atoms/SpacerDivider';
9 | import TypographyText from 'src/components/atoms/TypographyText';
10 | import ImportFromHtmlButton from 'src/components/controls/ImportFromHtmlButton';
11 | import ImportList from 'src/components/import/ImportList';
12 | import TextAreaDialog from 'src/components/import/TextAreaDialog';
13 | import TreeViewDialog from 'src/components/import/TreeViewDialog';
14 | import { useAppSelector } from 'src/redux/store';
15 |
16 | function ImportPage(): JSX.Element {
17 | const { t } = useTranslation();
18 | const [treeviewDialogOpen, setTreeviewDialogOpen] = useState(false);
19 | const [textareaDialogOpen, setTextareaDialogOpen] = useState(false);
20 | const backdropOpen = useAppSelector((s) => s.import.backdropOpen);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 | setTextareaDialogOpen(false)}
31 | />
32 |
33 | setTreeviewDialogOpen(false)}
36 | />
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | }
49 | onClick={() => setTreeviewDialogOpen(true)}>
50 | {t('Import')}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | }
67 | onClick={() => setTextareaDialogOpen(true)}>
68 | {t('Import')}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | );
78 | }
79 |
80 | export default ImportPage;
81 |
--------------------------------------------------------------------------------
/src/pages/SearchPage.tsx:
--------------------------------------------------------------------------------
1 | import { createRef } from 'preact';
2 |
3 | import { useEffect, useCallback, useMemo } from 'react';
4 |
5 | import { Search } from '@mui/icons-material';
6 | import { Container, Box } from '@mui/material';
7 | import { useTranslation } from 'react-i18next';
8 | import { useLocation } from 'react-router-dom';
9 | import browser from 'webextension-polyfill';
10 |
11 | import ErrorMessage from 'src/components/atoms/ErrorMessage';
12 | import FlexBox from 'src/components/atoms/FlexBox';
13 | import TypographyText from 'src/components/atoms/TypographyText';
14 | import DeleteDialog from 'src/components/dialogs/DeleteDialog';
15 | import ReadableDialog from 'src/components/readable/ReadableDialog';
16 | import PinnedReadLater from 'src/components/search/PinnedReadLater';
17 | import SearchResult from 'src/components/search/SearchResult';
18 | import { SEARCH_RESULTS_SHOULD_UPDATE } from 'src/constants';
19 | import { useInfiniteLoad } from 'src/hooks/useInfiniteLoad';
20 | import { scrollToTop } from 'src/libs/utils';
21 | import { useSearchQuery } from 'src/redux/services/elasticsearch/api';
22 | import { getReadLaterQueryBody } from 'src/redux/services/elasticsearch/config/queries';
23 | import {
24 | getSearchHits,
25 | removeSearchHit,
26 | resetSearchCacheAndHits,
27 | resetSearchHits,
28 | } from 'src/redux/slices/searchSlice';
29 | import { useAppDispatch, useAppSelector } from 'src/redux/store';
30 | import type { SearchMode } from 'src/types';
31 |
32 | function SearchPage(): JSX.Element {
33 | const { t } = useTranslation();
34 | const dispatch = useAppDispatch();
35 | const location = useLocation();
36 | const searchMode: SearchMode = useMemo(
37 | () => (location.state ? location.state : { keywords: '' }),
38 | [location],
39 | );
40 | const { searchHits, totalHits, isInitialized, hasMore, isLoading, error } =
41 | useAppSelector((s) => s.search);
42 | const loader = createRef();
43 |
44 | // Pinned read later
45 | const { data: readLaterHits } = useSearchQuery({ body: getReadLaterQueryBody() });
46 |
47 | // ! Scroll to top when state (SearchMode) changes
48 | useEffect(() => {
49 | scrollToTop();
50 | }, [searchMode]);
51 |
52 | // ! Initial fetch
53 | useEffect(() => {
54 | dispatch(resetSearchHits());
55 | dispatch(getSearchHits(searchMode));
56 | }, [dispatch, searchMode]);
57 |
58 | // ! Listening update message from popup
59 | useEffect(() => {
60 | browser.runtime.onMessage.addListener((message: string) => {
61 | if (message === SEARCH_RESULTS_SHOULD_UPDATE) {
62 | dispatch(resetSearchCacheAndHits());
63 | dispatch(getSearchHits({ keywords: '' }));
64 | }
65 | });
66 | }, [dispatch]);
67 |
68 | const loadMore = useCallback(
69 | async (entries) => {
70 | const target = entries[0];
71 |
72 | if (target.isIntersecting && hasMore) {
73 | dispatch(getSearchHits(searchMode));
74 | }
75 | },
76 | [dispatch, hasMore, searchMode],
77 | );
78 | useInfiniteLoad(loader, loadMore);
79 |
80 | const handleAfterDelete = (id: string, index: string) => {
81 | dispatch(removeSearchHit({ id, index }));
82 | };
83 |
84 | if (error) {
85 | return ;
86 | }
87 |
88 | return (
89 |
90 | handleAfterDelete(id, index)} />
91 |
92 | {searchMode.keywords === '' && readLaterHits && (
93 |
94 | )}
95 | {isInitialized && searchHits.length > 0 && (
96 |
97 |
98 |
99 |
108 | {isLoading && {t('- Loading -')}
}
109 | {!hasMore && {t('- End -')}
}
110 |
111 |
112 | )}
113 | {isInitialized && searchHits.length === 0 && (
114 |
115 |
116 |
117 |
118 | )}
119 |
120 | );
121 | }
122 |
123 | export default SearchPage;
124 |
--------------------------------------------------------------------------------
/src/pages/SettingsPage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { Update } from '@mui/icons-material';
4 | import { Container, TextField } from '@mui/material';
5 | import { useTranslation } from 'react-i18next';
6 | import { useToggle } from 'react-use';
7 |
8 | import FlexBox from 'src/components/atoms/FlexBox';
9 | import LoadableButton from 'src/components/atoms/LoadableButton';
10 | import SpacerDivider from 'src/components/atoms/SpacerDivider';
11 | import TypographyText from 'src/components/atoms/TypographyText';
12 | import ExportButton from 'src/components/controls/ExportButton';
13 | import { setElasticsearchUrl } from 'src/redux/slices/settingSlice';
14 | import { useAppDispatch, useAppSelector } from 'src/redux/store';
15 |
16 | function SettingsPage(): JSX.Element {
17 | const { t } = useTranslation();
18 | const dispatch = useAppDispatch();
19 |
20 | const esUrl = useAppSelector((s) => s.settings.elasticsearchUrl);
21 | const [url, setUrl] = useState(esUrl);
22 | const [isUpdatingUrl, setIsUpdatingUrl] = useToggle(false);
23 |
24 | const handleUpdateUrl = (e: React.FormEvent) => {
25 | e.preventDefault();
26 | setIsUpdatingUrl(true);
27 | dispatch(setElasticsearchUrl(url));
28 | setTimeout(() => setIsUpdatingUrl(false), 800);
29 | };
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
58 |
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
83 | export default SettingsPage;
84 |
--------------------------------------------------------------------------------
/src/pages/UpgradePage.tsx:
--------------------------------------------------------------------------------
1 | import { Upgrade } from '@mui/icons-material';
2 | import { Alert, Backdrop, CircularProgress, Container } from '@mui/material';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import FlexBox from 'src/components/atoms/FlexBox';
6 | import LoadableButton from 'src/components/atoms/LoadableButton';
7 | import TypographyText from 'src/components/atoms/TypographyText';
8 | import ExportButton from 'src/components/controls/ExportButton';
9 | import { migrateV1ToV2, reindex, reindexV3ToV4 } from 'src/redux/slices/esConfigSlice';
10 | import { useAppDispatch, useAppSelector } from 'src/redux/store';
11 |
12 | function UpgradePage(): JSX.Element {
13 | const { t } = useTranslation();
14 | const dispatch = useAppDispatch();
15 | const { mappingVersion, isUpgrading } = useAppSelector((s) => s.esconfig);
16 |
17 | const handleUpgrade = () => {
18 | if (mappingVersion === 'v0') {
19 | dispatch(reindex());
20 | } else if (mappingVersion === 'v1') {
21 | dispatch(migrateV1ToV2());
22 | } else if (mappingVersion === 'v2') {
23 | dispatch(reindex());
24 | } else if (mappingVersion === 'v3') {
25 | dispatch(reindexV3ToV4());
26 | }
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | {t('Click the button bellow to execute upgrading script.')}
54 |
55 |
56 |
57 | }
63 | />
64 |
65 |
66 |
67 | );
68 | }
69 |
70 | export default UpgradePage;
71 |
--------------------------------------------------------------------------------
/src/redux/rootReducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from '@reduxjs/toolkit';
2 | import type { Reducer } from '@reduxjs/toolkit';
3 |
4 | import { ElasticSearchApi } from 'src/redux/services/elasticsearch/api';
5 | import deleteDialogReducer from 'src/redux/slices/deleteDialogSlice';
6 | import drawerReducer from 'src/redux/slices/drawerSlice';
7 | import esConfigReducer from 'src/redux/slices/esConfigSlice';
8 | import importReducer from 'src/redux/slices/importSlice';
9 | import readableReducer from 'src/redux/slices/readableSlice';
10 | import scrollReducer from 'src/redux/slices/scrollSlice';
11 | import searchReducer from 'src/redux/slices/searchSlice';
12 | import settingReducer from 'src/redux/slices/settingSlice';
13 |
14 | const combinedReducer = combineReducers({
15 | drawer: drawerReducer,
16 | settings: settingReducer,
17 | import: importReducer,
18 | scroll: scrollReducer,
19 | deleteDialog: deleteDialogReducer,
20 | esconfig: esConfigReducer,
21 | readable: readableReducer,
22 | search: searchReducer,
23 | [ElasticSearchApi.reducerPath]: ElasticSearchApi.reducer,
24 | });
25 |
26 | const rootReducer: Reducer = (state, action) => {
27 | return combinedReducer(state, action);
28 | };
29 |
30 | export type RootState = ReturnType;
31 |
32 | export default rootReducer;
33 |
--------------------------------------------------------------------------------
/src/redux/selectors.ts:
--------------------------------------------------------------------------------
1 | import type { RootState } from 'src/redux/rootReducer';
2 | import type { DrawerState } from 'src/redux/slices/drawerSlice';
3 |
4 | export const selectDrawerState = (state: RootState): DrawerState => {
5 | return state.drawer;
6 | };
7 |
8 | type ImportNumbers = {
9 | success: number;
10 | error: number;
11 | warning: number;
12 | };
13 |
14 | export const selectImportResultsReport = (state: RootState): ImportNumbers => {
15 | let success = 0;
16 | let error = 0;
17 | let warning = 0;
18 | state.import.importResults.forEach((a) => {
19 | if (a.status === 'success') {
20 | success += 1;
21 | } else if (a.status === 'error') {
22 | error += 1;
23 | } else if (a.status === 'warning') {
24 | warning += 1;
25 | }
26 | });
27 | return { success, error, warning };
28 | };
29 |
30 | // export const selectSuccessImportsNumber = (state: RootState): number => {
31 | // const count = state.import.importResults.filter((a) => a.status === 'success').length;
32 | // return count;
33 | // };
34 |
--------------------------------------------------------------------------------
/src/redux/services/elasticsearch/config/analysis.ts:
--------------------------------------------------------------------------------
1 | export const jaAnalysis: Record = {
2 | filter: {
3 | extended: {
4 | type: 'sudachi_split',
5 | mode: 'extended',
6 | },
7 | search: {
8 | type: 'sudachi_split',
9 | mode: 'search',
10 | },
11 | synonym: {
12 | type: 'synonym',
13 | synonyms: ['関西国際空港,関空', '関西 => 近畿'],
14 | },
15 | romaji_readingform: {
16 | type: 'sudachi_readingform',
17 | use_romaji: true,
18 | },
19 | katakana_readingform: {
20 | type: 'sudachi_readingform',
21 | use_romaji: false,
22 | },
23 | },
24 | analyzer: {
25 | sudachi_analyzer: {
26 | filter: ['search', 'lowercase'],
27 | type: 'custom',
28 | tokenizer: 'sudachi_tokenizer',
29 | },
30 | sudachi_extended_analyzer: {
31 | filter: ['extended', 'lowercase'],
32 | type: 'custom',
33 | tokenizer: 'sudachi_tokenizer',
34 | },
35 | sudachi_baseform_analyzer: {
36 | filter: ['sudachi_baseform'],
37 | type: 'custom',
38 | tokenizer: 'sudachi_tokenizer',
39 | },
40 | sudachi_normalizedform_analyzer: {
41 | filter: ['sudachi_normalizedform'],
42 | type: 'custom',
43 | tokenizer: 'sudachi_tokenizer',
44 | },
45 | sudachi_readingform_analyzer: {
46 | filter: ['katakana_readingform'],
47 | type: 'custom',
48 | tokenizer: 'sudachi_tokenizer',
49 | },
50 | sudachi_romaji_analyzer: {
51 | filter: ['romaji_readingform'],
52 | type: 'custom',
53 | tokenizer: 'sudachi_tokenizer',
54 | },
55 | sudachi_synonym_analyzer: {
56 | filter: ['synonym', 'search'],
57 | type: 'custom',
58 | tokenizer: 'sudachi_tokenizer',
59 | },
60 | sudachi_a_analyzer: {
61 | filter: [],
62 | type: 'custom',
63 | tokenizer: 'sudachi_a_tokenizer',
64 | },
65 | sudachi_search_analyzer: {
66 | filter: ['search'],
67 | type: 'custom',
68 | tokenizer: 'sudachi_tokenizer',
69 | },
70 | },
71 | tokenizer: {
72 | sudachi_tokenizer: {
73 | type: 'sudachi_tokenizer',
74 | split_mode: 'C',
75 | discard_punctuation: true,
76 | resources_path: 'sudachi',
77 | settings_path: 'sudachi/sudachi.json',
78 | },
79 | sudachi_c_tokenizer: {
80 | type: 'sudachi_tokenizer',
81 | split_mode: 'C',
82 | discard_punctuation: true,
83 | resources_path: 'sudachi',
84 | settings_path: 'sudachi/sudachi.json',
85 | },
86 | sudachi_a_tokenizer: {
87 | type: 'sudachi_tokenizer',
88 | split_mode: 'A',
89 | discard_punctuation: true,
90 | resources_path: 'sudachi',
91 | settings_path: 'sudachi/sudachi.json',
92 | },
93 | },
94 | };
95 |
96 | export const deAnalysis: Record = {
97 | filter: {
98 | german_decompounder: {
99 | type: 'hyphenation_decompounder',
100 | word_list_path: 'analysis/de/dictionary-de.txt',
101 | hyphenation_patterns_path: 'analysis/de/de_DR.xml',
102 | only_longest_match: true,
103 | min_subword_size: 4,
104 | },
105 | german_stemmer: {
106 | type: 'stemmer',
107 | language: 'light_german',
108 | },
109 | german_stop: {
110 | type: 'stop',
111 | stopwords: '_german_',
112 | },
113 | },
114 | analyzer: {
115 | german_custom: {
116 | type: 'custom',
117 | tokenizer: 'standard',
118 | filter: [
119 | 'lowercase',
120 | 'german_decompounder',
121 | 'german_normalization',
122 | 'german_stemmer',
123 | 'german_stop',
124 | ],
125 | },
126 | },
127 | };
128 |
129 | export const URI_ANALYZER_NAME = 'uri_analyzer';
130 | export const uriAnalysis: Record = {
131 | analyzer: {
132 | uri_analyzer: {
133 | filter: 'lowercase',
134 | tokenizer: 'uri_tokenizer',
135 | type: 'custom',
136 | },
137 | },
138 | tokenizer: {
139 | uri_tokenizer: {
140 | type: 'char_group',
141 | tokenize_on_chars: ['whitespace', '.', '/', '?', '&', '='],
142 | },
143 | },
144 | };
145 |
--------------------------------------------------------------------------------
/src/redux/services/elasticsearch/config/mappings.ts:
--------------------------------------------------------------------------------
1 | import type { IndicesCreateRequest } from '@elastic/elasticsearch/api/types';
2 | import deepmerge from 'deepmerge';
3 |
4 | import { INDEX_NAME, INDICES, RESEARCHER_PIPELINE_NAME } from 'src/constants';
5 | import {
6 | deAnalysis,
7 | jaAnalysis,
8 | uriAnalysis,
9 | URI_ANALYZER_NAME,
10 | } from 'src/redux/services/elasticsearch/config/analysis';
11 | import type { Lang } from 'src/types';
12 |
13 | const getPipelineName = (lang: Lang) => {
14 | return lang === 'unknown' ? RESEARCHER_PIPELINE_NAME : undefined;
15 | };
16 |
17 | const getLangAnalysis = (lang: Lang) => {
18 | if (lang === 'ja') {
19 | return jaAnalysis;
20 | } else if (lang === 'de') {
21 | return deAnalysis;
22 | }
23 | return {};
24 | };
25 |
26 | const getLangAnalyzerName = (lang: Lang) => {
27 | const index = INDICES.find((a) => a.lang === lang);
28 | if (index) {
29 | return index.analyzerName;
30 | }
31 | return 'default';
32 | };
33 |
34 | export const getAliasName = (lang: Lang): string => {
35 | const index = INDICES.find((a) => a.lang === lang);
36 | if (index) {
37 | return index.aliasName;
38 | }
39 | return `${INDEX_NAME}_${lang}`;
40 | };
41 |
42 | export const getIndexName = (lang: Lang, version: string): string => {
43 | if (lang === 'unknown') {
44 | return `d_${INDEX_NAME}-${version}`;
45 | }
46 | return `d_${INDEX_NAME}_${lang}-${version}`;
47 | };
48 |
49 | export const createMapping = (lang: Lang): IndicesCreateRequest['body'] => {
50 | const pipelineName = getPipelineName(lang);
51 | const langAnalysis = getLangAnalysis(lang);
52 | const langAnalyzerName = getLangAnalyzerName(lang);
53 |
54 | const body: IndicesCreateRequest['body'] = {
55 | settings: {
56 | index: {
57 | number_of_shards: 1,
58 | number_of_replicas: 0,
59 | default_pipeline: pipelineName,
60 | },
61 | analysis: deepmerge(langAnalysis, uriAnalysis),
62 | },
63 | mappings: {
64 | dynamic: 'strict',
65 | properties: {
66 | language: {
67 | type: 'keyword',
68 | },
69 | url: {
70 | type: 'keyword',
71 | fields: {
72 | fulltext: {
73 | type: 'text',
74 | analyzer: URI_ANALYZER_NAME,
75 | },
76 | },
77 | },
78 | site: {
79 | type: 'keyword',
80 | fields: {
81 | fulltext: {
82 | type: 'text',
83 | analyzer: URI_ANALYZER_NAME,
84 | },
85 | },
86 | },
87 | title: {
88 | type: 'text',
89 | analyzer: langAnalyzerName,
90 | },
91 | excerpt: {
92 | type: 'text',
93 | analyzer: langAnalyzerName,
94 | },
95 | content: {
96 | type: 'text',
97 | analyzer: langAnalyzerName,
98 | index_options: 'offsets',
99 | term_vector: 'yes',
100 | },
101 | html: {
102 | type: 'keyword',
103 | index: false,
104 | ignore_above: 100,
105 | },
106 | stars: {
107 | type: 'integer',
108 | null_value: 0,
109 | },
110 | note: {
111 | type: 'text',
112 | analyzer: 'default',
113 | },
114 | tags: {
115 | type: 'keyword',
116 | },
117 | ogImage: {
118 | type: 'keyword',
119 | index: false,
120 | },
121 | screenshot: {
122 | type: 'binary',
123 | },
124 | isReadLater: {
125 | type: 'boolean',
126 | null_value: false,
127 | },
128 | indexedAt: {
129 | type: 'date',
130 | },
131 | bookmarkedAt: {
132 | type: 'date',
133 | },
134 | },
135 | },
136 | };
137 |
138 | return body;
139 | };
140 |
141 | export const createConfigMapping = (): IndicesCreateRequest['body'] => {
142 | const body: IndicesCreateRequest['body'] = {
143 | settings: {
144 | index: {
145 | number_of_shards: 1,
146 | number_of_replicas: 0,
147 | hidden: true,
148 | },
149 | },
150 | mappings: {
151 | dynamic: 'strict',
152 | properties: {
153 | version: {
154 | type: 'keyword',
155 | index: false,
156 | },
157 | },
158 | },
159 | };
160 |
161 | return body;
162 | };
163 |
--------------------------------------------------------------------------------
/src/redux/services/elasticsearch/config/pipeline.ts:
--------------------------------------------------------------------------------
1 | import type { IngestPipeline } from '@elastic/elasticsearch/api/types';
2 |
3 | import { APP_NAME } from 'src/constants';
4 |
5 | export const RESEARCHER_PIPELINE_BODY: IngestPipeline = {
6 | version: 1,
7 | description: `pipeline for ${APP_NAME}`,
8 | processors: [
9 | {
10 | inference: {
11 | model_id: 'lang_ident_model_1',
12 | // inference_config: {
13 | // classification: {
14 | // num_top_classes: 3,
15 | // },
16 | // },
17 | field_map: {
18 | content: 'text',
19 | },
20 | target_field: '_ml.lang_ident',
21 | },
22 | },
23 | {
24 | rename: {
25 | field: '_ml.lang_ident.predicted_value',
26 | target_field: 'language',
27 | ignore_failure: true,
28 | },
29 | },
30 | {
31 | set: {
32 | if: "['de', 'en', 'ja', 'ko', 'zh'].contains(ctx.language)",
33 | field: '_index',
34 | value: '{{_index}}_{{language}}',
35 | override: true,
36 | },
37 | },
38 | {
39 | set: {
40 | field: 'indexedAt',
41 | value: '{{_ingest.timestamp}}',
42 | },
43 | },
44 | {
45 | set: {
46 | if: 'ctx.stars == null',
47 | field: 'stars',
48 | value: 0,
49 | },
50 | },
51 | {
52 | set: {
53 | if: 'ctx.isReadLater == null',
54 | field: 'isReadLater',
55 | value: false,
56 | },
57 | },
58 | {
59 | remove: {
60 | field: '_ml',
61 | },
62 | },
63 | {
64 | urldecode: {
65 | field: 'url.fulltext',
66 | ignore_failure: true,
67 | ignore_missing: true,
68 | },
69 | },
70 | {
71 | urldecode: {
72 | field: 'site.fulltext',
73 | ignore_failure: true,
74 | ignore_missing: true,
75 | },
76 | },
77 | ],
78 | };
79 |
--------------------------------------------------------------------------------
/src/redux/services/elasticsearch/config/queries.ts:
--------------------------------------------------------------------------------
1 | import type { SearchRequest } from '@elastic/elasticsearch/api/types';
2 |
3 | import type { SearchMode } from 'src/types';
4 |
5 | type SearchBody = SearchRequest['body'];
6 |
7 | export const urlTermQuery = (str: string): SearchBody => {
8 | return {
9 | query: {
10 | term: {
11 | url: str,
12 | },
13 | },
14 | _source: ['stars', 'bookmarkedAt', 'isReadLater'],
15 | };
16 | };
17 |
18 | export const getReadLaterQueryBody = (): SearchBody => {
19 | return {
20 | query: {
21 | term: {
22 | isReadLater: true,
23 | },
24 | },
25 | sort: [{ bookmarkedAt: 'desc' }],
26 | _source: ['title', 'url', 'site'],
27 | };
28 | };
29 |
30 | export const getSearchBody = (mode: SearchMode): SearchBody => {
31 | // ! common body
32 | let body: SearchBody = {
33 | _source: {
34 | excludes: ['html', 'content'],
35 | },
36 | highlight: {
37 | pre_tags: [''],
38 | post_tags: [''],
39 | fields: {
40 | content: {
41 | number_of_fragments: 1,
42 | fragment_size: 350,
43 | no_match_size: 250,
44 | // order: 'score',
45 | },
46 | },
47 | },
48 | };
49 |
50 | if (mode.isReadLater != null) {
51 | body = {
52 | ...body,
53 | ...filterIsReadLaterQuery(),
54 | };
55 | }
56 |
57 | if (mode.stars != null) {
58 | body = {
59 | ...body,
60 | ...filterByStarsQuery(mode.stars),
61 | };
62 | }
63 |
64 | if (mode.sites != null && mode.sites.length > 0) {
65 | body = {
66 | ...body,
67 | ...filterBySites(mode.sites),
68 | };
69 | }
70 |
71 | if (mode.keywords != null) {
72 | body = {
73 | ...body,
74 | ...keywordSearchQuery(mode.keywords),
75 | };
76 | }
77 |
78 | return body;
79 | };
80 |
81 | const keywordSearchQuery = (keywords: string): SearchBody => {
82 | if (keywords === '') {
83 | return recentQuery();
84 | }
85 |
86 | const body: SearchBody = {
87 | query: {
88 | function_score: {
89 | query: {
90 | query_string: {
91 | fields: ['title^5', 'site.fulltext^3', 'url.fulltext', 'excerpt', 'content'],
92 | type: 'most_fields',
93 | query: keywords,
94 | },
95 | },
96 | functions: [
97 | {
98 | script_score: {
99 | script: "doc['stars'].value + 1",
100 | },
101 | },
102 | {
103 | exp: {
104 | bookmarkedAt: {
105 | scale: '10d',
106 | offset: '3d',
107 | decay: 0.8,
108 | },
109 | },
110 | },
111 | ],
112 | boost_mode: 'multiply',
113 | boost: 1,
114 | },
115 | },
116 | };
117 |
118 | return body;
119 | };
120 |
121 | const recentQuery = (): SearchBody => {
122 | const body: SearchBody = {
123 | query: { match_all: {} },
124 | sort: [{ bookmarkedAt: 'desc' }],
125 | };
126 | return body;
127 | };
128 |
129 | const filterByStarsQuery = (stars: number): SearchBody => {
130 | return {
131 | query: {
132 | term: {
133 | stars,
134 | },
135 | },
136 | sort: [{ bookmarkedAt: 'desc' }],
137 | };
138 | };
139 |
140 | const filterIsReadLaterQuery = (): SearchBody => {
141 | return {
142 | query: {
143 | term: {
144 | isReadLater: true,
145 | },
146 | },
147 | sort: [{ bookmarkedAt: 'desc' }],
148 | };
149 | };
150 |
151 | const filterBySites = (sites: string[]): SearchBody => {
152 | return {
153 | query: {
154 | terms: {
155 | site: sites,
156 | },
157 | },
158 | sort: [{ bookmarkedAt: 'desc' }],
159 | };
160 | };
161 |
--------------------------------------------------------------------------------
/src/redux/slices/deleteDialogSlice.ts:
--------------------------------------------------------------------------------
1 | import type { PayloadAction } from '@reduxjs/toolkit';
2 | import { createSlice } from '@reduxjs/toolkit';
3 |
4 | type DeleteDialogState = {
5 | isOpen: boolean;
6 | id: string | undefined;
7 | index: string | undefined;
8 | };
9 |
10 | const initialState: DeleteDialogState = {
11 | isOpen: false,
12 | id: undefined,
13 | index: undefined,
14 | };
15 |
16 | const deleteDialogSlice = createSlice({
17 | name: 'deleteDialog',
18 | initialState,
19 | reducers: {
20 | openDeleteDialog(state, action: PayloadAction<{ id: string; index: string }>) {
21 | const { id, index } = action.payload;
22 | state.isOpen = true;
23 | state.id = id;
24 | state.index = index;
25 | },
26 | closeDeleteDialog(state) {
27 | state.isOpen = false;
28 | state.id = undefined;
29 | state.index = undefined;
30 | },
31 | },
32 | });
33 |
34 | export default deleteDialogSlice.reducer;
35 |
36 | export const { openDeleteDialog, closeDeleteDialog } = deleteDialogSlice.actions;
37 |
--------------------------------------------------------------------------------
/src/redux/slices/drawerSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | export type DrawerState = {
4 | isLeftDrawerPrimaryOpen: boolean;
5 | isLeftDrawerSecondaryOpen: boolean;
6 | isOverlayDrawerOpen: boolean;
7 | isOverlayDrawerEnabled: boolean;
8 | isAutoToggleEnabled: boolean;
9 | };
10 |
11 | const initialState: DrawerState = {
12 | isLeftDrawerPrimaryOpen: false,
13 | isLeftDrawerSecondaryOpen: false,
14 | isOverlayDrawerOpen: false,
15 | isOverlayDrawerEnabled: true,
16 | isAutoToggleEnabled: true,
17 | };
18 |
19 | const drawerSlice = createSlice({
20 | name: 'drawer',
21 | initialState,
22 | reducers: {
23 | toggleIsAutoToggleEnabled(state) {
24 | state.isAutoToggleEnabled = !state.isAutoToggleEnabled;
25 | },
26 | // Primary Left Drawer
27 | openLeftDrawerPrimary(state) {
28 | state.isLeftDrawerPrimaryOpen = true;
29 | },
30 | closeLeftDrawerPrimary(state) {
31 | state.isLeftDrawerPrimaryOpen = false;
32 | },
33 | toggleLeftDrawerPrimary(state) {
34 | state.isLeftDrawerPrimaryOpen = !state.isLeftDrawerPrimaryOpen;
35 | },
36 | // Secondary Left Drawer
37 | openLeftDrawerSecondary(state) {
38 | state.isLeftDrawerSecondaryOpen = true;
39 | },
40 | closeLeftDrawerSecondary(state) {
41 | state.isLeftDrawerSecondaryOpen = false;
42 | },
43 | toggleLeftDrawerSecondary(state) {
44 | state.isLeftDrawerSecondaryOpen = !state.isLeftDrawerSecondaryOpen;
45 | },
46 | // Secondary Left Drawer
47 | openOverlayDrawer(state) {
48 | state.isOverlayDrawerOpen = true;
49 | },
50 | closeOverlayDrawer(state) {
51 | state.isOverlayDrawerOpen = false;
52 | },
53 | toggleOverlayDrawer(state) {
54 | state.isOverlayDrawerOpen = !state.isOverlayDrawerOpen;
55 | },
56 | closeAllDrawers(state) {
57 | state.isLeftDrawerSecondaryOpen = false;
58 | state.isLeftDrawerPrimaryOpen = false;
59 | },
60 | },
61 | });
62 |
63 | export default drawerSlice.reducer;
64 |
65 | export const {
66 | toggleIsAutoToggleEnabled,
67 | openLeftDrawerPrimary,
68 | closeLeftDrawerPrimary,
69 | toggleLeftDrawerPrimary,
70 | openLeftDrawerSecondary,
71 | closeLeftDrawerSecondary,
72 | toggleLeftDrawerSecondary,
73 | openOverlayDrawer,
74 | closeOverlayDrawer,
75 | toggleOverlayDrawer,
76 | closeAllDrawers,
77 | } = drawerSlice.actions;
78 |
--------------------------------------------------------------------------------
/src/redux/slices/readableSlice.ts:
--------------------------------------------------------------------------------
1 | import type { PayloadAction } from '@reduxjs/toolkit';
2 | import { createSlice } from '@reduxjs/toolkit';
3 |
4 | import type { BookmarkResponse } from 'src/types';
5 |
6 | type ReadableState = {
7 | open: boolean;
8 | id?: string;
9 | index?: string;
10 | bookmarkResponse?: BookmarkResponse;
11 | };
12 |
13 | const initialState: ReadableState = {
14 | open: false,
15 | id: undefined,
16 | index: undefined,
17 | bookmarkResponse: undefined,
18 | };
19 |
20 | const readableSlice = createSlice({
21 | name: 'readable',
22 | initialState,
23 | reducers: {
24 | openReadable(state, action: PayloadAction>) {
25 | const { id, index, bookmarkResponse } = action.payload;
26 | if (id && index && bookmarkResponse) {
27 | state.open = true;
28 | state.id = id;
29 | state.index = index;
30 | state.bookmarkResponse = bookmarkResponse;
31 | }
32 | },
33 | closeReadable(state) {
34 | state.open = false;
35 | state.id = undefined;
36 | state.index = undefined;
37 | state.bookmarkResponse = undefined;
38 | },
39 | },
40 | });
41 |
42 | export default readableSlice.reducer;
43 |
44 | export const { openReadable, closeReadable } = readableSlice.actions;
45 |
--------------------------------------------------------------------------------
/src/redux/slices/scrollSlice.ts:
--------------------------------------------------------------------------------
1 | import type { PayloadAction } from '@reduxjs/toolkit';
2 | import { createSlice } from '@reduxjs/toolkit';
3 |
4 | type Coordinate = {
5 | x: number;
6 | y: number;
7 | };
8 |
9 | export type ScrollState = {
10 | query: string;
11 | coordinate: Coordinate;
12 | };
13 |
14 | const initialState: ScrollState = {
15 | query: '',
16 | coordinate: { x: 0, y: 0 },
17 | };
18 |
19 | const scrollSlice = createSlice({
20 | name: 'scroll',
21 | initialState,
22 | reducers: {
23 | setScrollPosition(state, action: PayloadAction) {
24 | const { coordinate, query } = action.payload;
25 | state.query = query;
26 | state.coordinate = coordinate;
27 | },
28 | },
29 | });
30 |
31 | export default scrollSlice.reducer;
32 |
33 | export const { setScrollPosition } = scrollSlice.actions;
34 |
--------------------------------------------------------------------------------
/src/redux/slices/searchSlice.ts:
--------------------------------------------------------------------------------
1 | import type { SearchHit } from '@elastic/elasticsearch/api/types';
2 | import type { PayloadAction, SerializedError } from '@reduxjs/toolkit';
3 | import { createSlice } from '@reduxjs/toolkit';
4 | import type { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query';
5 |
6 | import { getSearchTotalHits } from 'src/libs/elasticsearch';
7 | import { ElasticSearchApi } from 'src/redux/services/elasticsearch/api';
8 | import { getSearchBody } from 'src/redux/services/elasticsearch/config/queries';
9 | import type { AppThunk } from 'src/redux/store';
10 | import type { BookmarkResponse, BookmarkSearchResponse, SearchMode } from 'src/types';
11 |
12 | const SEARCH_SIZE = 12;
13 |
14 | type State = {
15 | searchHits: SearchHit[];
16 | totalHits: number;
17 | from: number;
18 | isInitialized: boolean;
19 | hasMore: boolean;
20 | isLoading: boolean;
21 | error?: FetchBaseQueryError | SerializedError;
22 | };
23 |
24 | const initialState: State = {
25 | searchHits: [],
26 | totalHits: 0,
27 | from: 0,
28 | isInitialized: false,
29 | hasMore: false,
30 | isLoading: false,
31 | error: undefined,
32 | };
33 |
34 | const searchSlice = createSlice({
35 | name: 'search',
36 | initialState,
37 | reducers: {
38 | setSearchError(
39 | state,
40 | action: PayloadAction,
41 | ) {
42 | state.error = action.payload;
43 | },
44 | setIsLoading(state, action: PayloadAction) {
45 | state.isLoading = action.payload;
46 | },
47 | setInitialSearchHits(state, action: PayloadAction) {
48 | const data = action.payload;
49 | const total = getSearchTotalHits(data.hits.total);
50 | state.searchHits = data.hits.hits;
51 | state.totalHits = total;
52 | state.from = SEARCH_SIZE;
53 | state.hasMore = total >= SEARCH_SIZE;
54 | state.isInitialized = true;
55 | },
56 | pushSearchHits(state, action: PayloadAction) {
57 | const data = action.payload;
58 | state.searchHits.push(...data.hits.hits);
59 | state.from = state.from + SEARCH_SIZE;
60 | state.hasMore = state.totalHits >= state.from + SEARCH_SIZE;
61 | },
62 | removeSearchHit(state, action: PayloadAction<{ id: string; index: string }>) {
63 | const { id, index } = action.payload;
64 | const idx = state.searchHits.findIndex((a) => a._id === id && a._index === index);
65 | if (idx !== -1) {
66 | state.searchHits.splice(idx, 1);
67 | const total = state.totalHits - 1;
68 | const currentFrom = state.from - 1;
69 | state.totalHits = total;
70 | state.from = currentFrom;
71 | state.hasMore = total >= currentFrom + SEARCH_SIZE;
72 | }
73 | },
74 | updateSearchHit(
75 | state,
76 | action: PayloadAction<{
77 | id: string;
78 | index: string;
79 | patch: Partial;
80 | }>,
81 | ) {
82 | const { id, index, patch } = action.payload;
83 | const idx = state.searchHits.findIndex((a) => a._id === id && a._index === index);
84 | if (idx !== -1) {
85 | const hit = state.searchHits[idx];
86 | if (hit._source != null) {
87 | hit._source = {
88 | ...hit._source,
89 | ...patch,
90 | };
91 | }
92 | }
93 | },
94 | resetSearchHits() {
95 | return initialState;
96 | },
97 | },
98 | });
99 |
100 | export default searchSlice.reducer;
101 |
102 | export const {
103 | setSearchError,
104 | setIsLoading,
105 | setInitialSearchHits,
106 | pushSearchHits,
107 | resetSearchHits,
108 | removeSearchHit,
109 | updateSearchHit,
110 | } = searchSlice.actions;
111 |
112 | export const getSearchHits =
113 | (searchMode: SearchMode): AppThunk =>
114 | async (dispatch, getState) => {
115 | dispatch(setIsLoading(true));
116 | const from = getState().search.from;
117 | const body = getSearchBody(searchMode);
118 | const { data, error } = await dispatch(
119 | ElasticSearchApi.endpoints.search.initiate({ size: SEARCH_SIZE, from, body }),
120 | );
121 | if (data) {
122 | if (from === 0) {
123 | dispatch(setInitialSearchHits(data));
124 | } else {
125 | dispatch(pushSearchHits(data));
126 | }
127 | } else if (error) {
128 | dispatch(setSearchError(error));
129 | }
130 | dispatch(setIsLoading(false));
131 | };
132 |
133 | // ! Reset cache and search hits.
134 | // ! This will immediately remove all existing cache entries, and all queries will be considered 'uninitialized'.
135 | export const resetSearchCacheAndHits = (): AppThunk => async (dispatch) => {
136 | dispatch(ElasticSearchApi.util.resetApiState());
137 | dispatch(resetSearchHits());
138 | };
139 |
--------------------------------------------------------------------------------
/src/redux/slices/settingSlice.ts:
--------------------------------------------------------------------------------
1 | import type { PayloadAction } from '@reduxjs/toolkit';
2 | import { createSlice } from '@reduxjs/toolkit';
3 |
4 | import type { ListViewType } from 'src/types';
5 |
6 | const DEFAULT_URL = 'http://localhost:9200/';
7 |
8 | export type SettingState = {
9 | isDarkMode: boolean;
10 | listViewType: ListViewType;
11 | elasticsearchUrl: string;
12 | };
13 |
14 | const initialState: SettingState = {
15 | isDarkMode: false,
16 | listViewType: 'headline',
17 | elasticsearchUrl: DEFAULT_URL,
18 | };
19 |
20 | const settingsSlice = createSlice({
21 | name: 'settings',
22 | initialState,
23 | reducers: {
24 | toggleTheme(state) {
25 | state.isDarkMode = !state.isDarkMode;
26 | },
27 | setListViewType(state, action: PayloadAction) {
28 | state.listViewType = action.payload;
29 | },
30 | setElasticsearchUrl(state, action: PayloadAction) {
31 | try {
32 | const url = new URL(action.payload);
33 | state.elasticsearchUrl = url.toString();
34 | } catch {
35 | state.elasticsearchUrl = DEFAULT_URL;
36 | }
37 | },
38 | },
39 | });
40 |
41 | export default settingsSlice.reducer;
42 |
43 | export const { toggleTheme, setListViewType, setElasticsearchUrl } =
44 | settingsSlice.actions;
45 |
--------------------------------------------------------------------------------
/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import type { ThunkAction, Action } from '@reduxjs/toolkit';
2 | import { configureStore } from '@reduxjs/toolkit';
3 | import { setupListeners } from '@reduxjs/toolkit/dist/query';
4 | import type { TypedUseSelectorHook } from 'react-redux';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import {
7 | persistStore,
8 | persistReducer,
9 | FLUSH,
10 | REHYDRATE,
11 | PAUSE,
12 | PERSIST,
13 | PURGE,
14 | REGISTER,
15 | } from 'redux-persist';
16 | import { localStorage } from 'redux-persist-webextension-storage';
17 |
18 | import type { RootState } from './rootReducer';
19 | import rootReducer from './rootReducer';
20 |
21 | import { ElasticSearchApi } from 'src/redux/services/elasticsearch/api';
22 |
23 | const persistedReducer = persistReducer(
24 | {
25 | key: 'root',
26 | storage: localStorage,
27 | whitelist: ['settings'],
28 | },
29 | rootReducer,
30 | );
31 |
32 | // ! https://redux-toolkit.js.org/usage/usage-guide
33 | const store = configureStore({
34 | reducer: persistedReducer,
35 | middleware: (getDefaultMiddleware) => {
36 | return getDefaultMiddleware({
37 | serializableCheck: {
38 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
39 | },
40 | }).concat(ElasticSearchApi.middleware);
41 | },
42 | });
43 |
44 | setupListeners(store.dispatch);
45 |
46 | // export persistor
47 | export const persistor = persistStore(store);
48 |
49 | // Export a hook that can be reused to resolve types
50 | export type AppDispatch = typeof store.dispatch;
51 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
52 | export const useAppDispatch = () => useDispatch();
53 | export const useAppSelector: TypedUseSelectorHook = useSelector;
54 | export type AppThunk = ThunkAction>;
55 |
56 | export default store;
57 |
58 | // hot reloading for redux
59 | // if (process.env.NODE_ENV === 'development' && import.meta.hot) {
60 | // console.log('update redux store');
61 | // import.meta.hot.accept('./rootReducer', () => {
62 | // const newRootReducer = require('./rootReducer').default;
63 | // store.replaceReducer(newRootReducer);
64 | // });
65 | // }
66 |
--------------------------------------------------------------------------------
/src/styles/global.scss:
--------------------------------------------------------------------------------
1 | .json {
2 | pre {
3 | padding: 1em;
4 | border-radius: 4px;
5 | word-break: break-all;
6 | word-wrap: break-word;
7 | white-space: pre-wrap;
8 | font: 16px Monaco, Consolas, 'Andale Mono', 'DejaVu Sans Mono', monospace;
9 | line-height: 1.3em;
10 | border-radius: 2px;
11 | background: #282c34; // atom-one-dark
12 | color: #abb2bf; // atom-one-dark
13 | code {
14 | border-radius: 2px;
15 | background: #282c34; // atom-one-dark
16 | color: #abb2bf; // atom-one-dark
17 | }
18 | }
19 | }
20 |
21 | .backdrop {
22 | z-index: 10000 !important;
23 | }
24 |
25 | .lineClamp {
26 | overflow: hidden;
27 | text-overflow: ellipsis;
28 | display: -webkit-box;
29 | -webkit-box-orient: vertical;
30 | }
31 |
--------------------------------------------------------------------------------
/src/styles/readable.scss:
--------------------------------------------------------------------------------
1 | .readable {
2 | word-wrap: break-word;
3 | box-sizing: border-box;
4 | width: 100%;
5 | font-size: 16px;
6 |
7 | h1 {
8 | font-size: 1.4em;
9 | }
10 |
11 | h2 {
12 | font-size: 1.3em;
13 | line-height: 1.51em;
14 | }
15 |
16 | h3 {
17 | font-size: 1.2em;
18 | line-height: 1.5em;
19 | }
20 |
21 | h4 {
22 | font-size: 1.2em;
23 | }
24 |
25 | h5 {
26 | font-size: 1.2em;
27 | }
28 |
29 | h6 {
30 | font-size: 1.2em;
31 | }
32 |
33 | ul {
34 | padding-left: 32px;
35 | }
36 |
37 | a {
38 | color: #64b5f6;
39 | }
40 |
41 | img {
42 | max-width: 100%;
43 | height: auto;
44 | opacity: 0;
45 | animation: fadein 0.5s ease-in forwards;
46 | }
47 |
48 | @keyframes fadein {
49 | 100% {
50 | opacity: 1;
51 | }
52 | }
53 |
54 | p {
55 | line-height: 1.8em;
56 | img {
57 | max-width: 100%;
58 | height: auto;
59 | }
60 | }
61 |
62 | p:empty {
63 | display: none;
64 | }
65 |
66 | code {
67 | border-radius: 2px;
68 | padding: 2px;
69 | background: #bfbfbf;
70 | color: #000000;
71 | }
72 |
73 | pre {
74 | padding: 1em;
75 | border-radius: 4px;
76 | word-break: break-all;
77 | word-wrap: break-word;
78 | white-space: pre-wrap;
79 | font: 16px Monaco, Consolas, 'Andale Mono', 'DejaVu Sans Mono', monospace;
80 | line-height: 1.3em;
81 | code {
82 | border-radius: 2px;
83 | background: #282c34; // atom-one-dark
84 | color: #abb2bf; // atom-one-dark
85 | }
86 | }
87 |
88 | blockquote {
89 | font-style: italic;
90 | border-radius: 2px;
91 | background: #282c34; // atom-one-dark
92 | color: #abb2bf; // atom-one-dark
93 | p {
94 | padding: 1em;
95 | }
96 | }
97 |
98 | table {
99 | width: 100%;
100 | table-layout: fixed;
101 | word-break: break-all;
102 | word-wrap: break-word;
103 | border-collapse: collapse;
104 | tbody {
105 | border: 1px solid #282c34;
106 | width: 100%;
107 | }
108 | th {
109 | background-color: #adadad;
110 | color: #000;
111 | }
112 | td,
113 | th {
114 | padding: 4px;
115 | border-collapse: collapse;
116 | border: 1px solid #282c34;
117 | }
118 | }
119 |
120 | .videoWrapper {
121 | position: relative;
122 | padding-bottom: 56.25%; /* 16:9 */
123 | height: 0;
124 | margin-top: 10px;
125 | margin-bottom: 10px;
126 |
127 | iframe {
128 | position: absolute;
129 | top: 0;
130 | left: 0;
131 | width: 100%;
132 | height: 100%;
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/themes/common.ts:
--------------------------------------------------------------------------------
1 | import { grey } from '@mui/material/colors';
2 | import type { TypographyOptions } from '@mui/material/styles/createTypography';
3 |
4 | import {
5 | BREAKPOINT_LARGE,
6 | BREAKPOINT_MEDIUM,
7 | BREAKPOINT_SMALL,
8 | BREAKPOINT_X_LARGE,
9 | BREAKPOINT_X_SMALL,
10 | } from 'src/constants';
11 |
12 | // common theme
13 | export const commonPrimaryMain = '#4c96d7';
14 | export const commonBackgroundWhite = '#efefef';
15 |
16 | export const commonTextBlack = grey[800];
17 | export const commonTextWhite = '#efefef';
18 | export const commonTextGray = '#7a7a7a';
19 |
20 | // custom breakpoints
21 | export const commonBreakPoints = {
22 | values: {
23 | xs: BREAKPOINT_X_SMALL,
24 | sm: BREAKPOINT_SMALL,
25 | md: BREAKPOINT_MEDIUM,
26 | lg: BREAKPOINT_LARGE,
27 | xl: BREAKPOINT_X_LARGE,
28 | },
29 | };
30 |
31 | // common pallet
32 | export const commonPaletteError = {
33 | main: '#ee0000',
34 | dark: '#c40000',
35 | };
36 |
37 | // common config
38 | // https://material-ui.com/ja/customization/default-theme/?expand-path=$.typography
39 | export const commonTypography: TypographyOptions = {
40 | fontFamily: [
41 | 'Roboto',
42 | '-apple-system',
43 | 'BlinkMacSystemFont',
44 | 'Hiragino Kaku Gothic ProN',
45 | 'Hiragino Sans',
46 | 'Arial',
47 | 'BIZ UDPGothic',
48 | 'Meiryo',
49 | 'sans-serif',
50 | 'Segoe UI',
51 | 'Helvetica Neue',
52 | 'Apple Color Emoji',
53 | 'Segoe UI Emoji',
54 | 'Segoe UI Symbol',
55 | ].join(','),
56 | fontSize: 14,
57 | h1: {
58 | fontWeight: 500,
59 | fontSize: '3rem',
60 | lineHeight: 1.167,
61 | letterSpacing: '0em',
62 | },
63 | h2: {
64 | fontWeight: 'normal',
65 | // fontSize: '2.125rem',
66 | fontSize: '1.3rem',
67 | lineHeight: 1.6,
68 | // letterSpacing: '0.00735em',
69 | },
70 | h3: {
71 | fontWeight: 500,
72 | fontSize: '1.5rem',
73 | lineHeight: 1.334,
74 | letterSpacing: '0em',
75 | },
76 | h4: {
77 | fontWeight: 500,
78 | fontSize: '1.25rem',
79 | lineHeight: 1.6,
80 | letterSpacing: '0.0075em',
81 | },
82 | h5: {
83 | fontWeight: 400,
84 | fontSize: '1rem',
85 | lineHeight: 1.2,
86 | letterSpacing: '0.0075em',
87 | },
88 | h6: {
89 | fontWeight: 400,
90 | fontSize: '1rem',
91 | lineHeight: 1.2,
92 | letterSpacing: '0.0075em',
93 | },
94 | body1: {
95 | fontSize: '0.9rem',
96 | },
97 | // overline: {
98 | // fontWeight: "bold",
99 | // },
100 | };
101 |
102 | export const commonMuiButtonOverrides = {
103 | root: {
104 | fontSize: '0.875rem',
105 | letterSpacing: '0.08rem',
106 | borderRadius: '1px',
107 | },
108 | text: {
109 | paddingLeft: 12,
110 | paddingRight: 12,
111 | },
112 | };
113 |
114 | export const commonMuiDrawer = {
115 | paperAnchorLeft: {
116 | borderRightColor: 'none',
117 | borderColor: 'transparent !important',
118 | },
119 | };
120 |
--------------------------------------------------------------------------------
/src/themes/darkTheme.ts:
--------------------------------------------------------------------------------
1 | import type { Theme } from '@mui/material';
2 | import { createTheme } from '@mui/material';
3 | import { blue, green, grey } from '@mui/material/colors';
4 |
5 | import {
6 | commonBreakPoints,
7 | commonMuiButtonOverrides,
8 | commonMuiDrawer,
9 | commonPaletteError,
10 | commonPrimaryMain,
11 | commonTypography,
12 | } from './common';
13 |
14 | // background
15 | const backgroundDefault = '#181818';
16 | const backgroundSecondary = '#212121';
17 |
18 | // palette
19 | const darkThemePrimaryMain = commonPrimaryMain;
20 |
21 | // text
22 | const textPrimary = grey[50];
23 | const textSecondary = blue[300];
24 |
25 | // icon
26 | // const iconColor = grey[300];
27 |
28 | const darkTheme = (): Theme => {
29 | return createTheme({
30 | typography: commonTypography,
31 | palette: {
32 | mode: 'dark',
33 | primary: {
34 | main: darkThemePrimaryMain,
35 | contrastText: grey[50],
36 | },
37 | secondary: {
38 | main: green[500],
39 | contrastText: grey[50],
40 | },
41 | text: {
42 | primary: textPrimary,
43 | secondary: textSecondary,
44 | },
45 | background: {
46 | default: backgroundDefault,
47 | paper: backgroundSecondary,
48 | },
49 | error: commonPaletteError,
50 | },
51 | breakpoints: commonBreakPoints,
52 | components: {
53 | MuiButton: {
54 | styleOverrides: commonMuiButtonOverrides,
55 | },
56 | MuiDrawer: {
57 | styleOverrides: commonMuiDrawer,
58 | },
59 | },
60 | });
61 | };
62 |
63 | export default darkTheme;
64 |
--------------------------------------------------------------------------------
/src/themes/lightTheme.ts:
--------------------------------------------------------------------------------
1 | import type { Theme } from '@mui/material';
2 | import { createTheme } from '@mui/material';
3 | import { green } from '@mui/material/colors';
4 |
5 | import {
6 | commonBreakPoints,
7 | commonMuiButtonOverrides,
8 | commonMuiDrawer,
9 | commonPaletteError,
10 | commonPrimaryMain,
11 | commonTextWhite,
12 | commonTypography,
13 | } from './common';
14 |
15 | // background
16 | const backgroundDefault = '#ffffff';
17 | // const backgroundSecondary = '#efefef';
18 | const backgroundSecondary = '#f5f5f5';
19 | // text
20 | const textPrimary = '#2B2828';
21 | const textSecondary = '#1a0dab';
22 | // icons
23 | // export const lightThemeIconColor = "#7a7a7a";
24 |
25 | const lightTheme = (): Theme => {
26 | return createTheme({
27 | typography: commonTypography,
28 | palette: {
29 | primary: {
30 | main: commonPrimaryMain,
31 | },
32 | secondary: {
33 | main: green[500],
34 | contrastText: commonTextWhite,
35 | },
36 | text: {
37 | primary: textPrimary,
38 | secondary: textSecondary,
39 | },
40 | background: {
41 | default: backgroundDefault,
42 | paper: backgroundSecondary,
43 | },
44 | error: commonPaletteError,
45 | },
46 | breakpoints: commonBreakPoints,
47 | components: {
48 | MuiButton: {
49 | styleOverrides: commonMuiButtonOverrides,
50 | },
51 | MuiDrawer: {
52 | styleOverrides: commonMuiDrawer,
53 | },
54 | },
55 | });
56 | };
57 |
58 | export default lightTheme;
59 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | IndexResponse,
3 | SearchResponse,
4 | SearchRequest,
5 | } from '@elastic/elasticsearch/api/types';
6 | import type { AlertColor } from '@mui/material';
7 |
8 | export type ReadabilityArticle = {
9 | /** article title */
10 | title: string;
11 | /** author metadata */
12 | byline: string;
13 | /** content direction */
14 | dir: string;
15 | /** HTML of processed article content */
16 | content: T;
17 | /** text content of the article (all HTML removed) */
18 | textContent: string;
19 | /** length of an article, in characters */
20 | length: number;
21 | /** article description, or short excerpt from the content */
22 | excerpt: string;
23 | siteName: string;
24 | } | null;
25 |
26 | export type Bookmark = {
27 | url: string;
28 | site: string;
29 | title: string;
30 | excerpt: string;
31 | content: string;
32 | html: string;
33 | bookmarkedAt: string; // ISOString
34 | note?: string;
35 | tags?: string[];
36 | stars?: number;
37 | isReadLater?: boolean;
38 | screenshot?: string;
39 | ogImage?: string;
40 | };
41 |
42 | export type BookmarkResponse = {
43 | url: string;
44 | site: string;
45 | language: string;
46 | title: string;
47 | excerpt: string;
48 | content?: string;
49 | html?: string;
50 | note: string;
51 | tags: string[];
52 | stars: number;
53 | isReadLater: boolean;
54 | screenshot: string;
55 | ogImage: string;
56 | bookmarkedAt: string; // ISOString
57 | indexedAt: string;
58 | };
59 |
60 | export type BookmarkResponseDoc = {
61 | id: string;
62 | index: string;
63 | bookmarkResponse: BookmarkResponse;
64 | };
65 |
66 | export type BookmarkIndexResponse = IndexResponse & {
67 | url?: string;
68 | bookmarkedAt?: string;
69 | stars?: number;
70 | };
71 |
72 | export type BookmarkSearchResponse = SearchResponse;
73 |
74 | export type ExportBookmarkResponse = SearchResponse<
75 | Pick
76 | >;
77 |
78 | export type URLSearchRequest = {
79 | url: string;
80 | } & SearchRequest;
81 |
82 | export type ListViewType = 'headline' | 'column' | 'simple' | 'imageHeadline';
83 |
84 | export type BrowserBookmarksType = {
85 | checked?: boolean;
86 | } & browser.Bookmarks.BookmarkTreeNode;
87 |
88 | export type ImportResult = {
89 | url: string;
90 | status: AlertColor;
91 | message: string;
92 | };
93 |
94 | export type UrlAndTitle = {
95 | url: string;
96 | title: string;
97 | };
98 |
99 | export type ElasticSearchDoc =
100 | | {
101 | id: string;
102 | index: string;
103 | bookmarkedAt: string;
104 | stars?: number;
105 | isReadLater?: boolean;
106 | }
107 | | undefined;
108 |
109 | export type EmbeddableType = {
110 | isEmbeddable: boolean;
111 | provider?: 'youtube';
112 | identifier?: string;
113 | };
114 |
115 | export type SearchMode = {
116 | keywords?: string;
117 | isReadLater?: boolean;
118 | stars?: number;
119 | sites?: string[];
120 | };
121 |
122 | export type Lang = 'ja' | 'en' | 'de' | 'ko' | 'zh' | 'unknown';
123 |
124 | export type Index = {
125 | lang: Lang;
126 | analyzerName: string;
127 | aliasName: string;
128 | };
129 |
130 | export type Config = {
131 | version: string;
132 | };
133 |
--------------------------------------------------------------------------------
/src/views/app/App.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from '@mui/material';
2 |
3 | import EsCheck from 'src/components/app/EsCheck';
4 | import Routing from 'src/components/app/Routing';
5 | import AppLayout from 'src/components/layout/AppLayout';
6 | import useCustomTheme from 'src/hooks/useCustomTheme';
7 |
8 | import 'src/styles/global.scss';
9 | import 'src/styles/readable.scss';
10 |
11 | function App(): JSX.Element {
12 | const theme = useCustomTheme();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default App;
26 |
--------------------------------------------------------------------------------
/src/views/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | RE:SEARCHER
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/views/app/main.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'preact';
2 |
3 | import { Provider } from 'react-redux';
4 | import { MemoryRouter } from 'react-router-dom';
5 | import { PersistGate } from 'redux-persist/integration/react';
6 |
7 | import store, { persistor } from 'src/redux/store';
8 | import App from 'src/views/app/App';
9 |
10 | import 'src/i18n';
11 |
12 | const Main = () => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
25 | render(, document.getElementById('root')!);
26 |
--------------------------------------------------------------------------------
/src/views/popup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Popup
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/views/popup/main.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'preact';
2 |
3 | import { Provider } from 'react-redux';
4 | import { PersistGate } from 'redux-persist/integration/react';
5 |
6 | import store, { persistor } from 'src/redux/store';
7 | import Popup from 'src/views/popup/Popup';
8 |
9 | import 'src/i18n';
10 |
11 | const Main = () => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
22 | render(, document.getElementById('root')!);
23 |
--------------------------------------------------------------------------------
/src/webext/content_scripts.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 |
3 | import { GET_READABILITY_ARTICLE } from 'src/constants';
4 |
5 | browser.runtime.onMessage.addListener((message) => {
6 | if (message === GET_READABILITY_ARTICLE) {
7 | return Promise.resolve(document.documentElement.innerHTML);
8 | }
9 | return Promise.reject();
10 | });
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Basic Options */
4 | "target": "ESNEXT", // Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'.
5 | "module": "ESNEXT", // Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'.
6 | "lib": ["DOM", "ESNext", "DOM.Iterable"], // Specify library files to be included in the compilation:
7 | "allowJs": true, // Allow javascript files to be compiled.
8 | // "checkJs": true, // Report errors in .js files.
9 | // "jsx": "react", // Specify JSX code generation: 'preserve', 'react-native', or 'react'.
10 | // "jsxFactory": "h" // Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h.
11 | "jsx": "preserve",
12 | "jsxFactory": "h",
13 | "jsxFragmentFactory": "Fragment",
14 | // "declaration": true, // Generates corresponding '.d.ts' file.
15 | // "sourceMap": true, // Generates corresponding '.map' file.
16 | // "outFile": "./", // Concatenate and emit output to single file.
17 | // "outDir": "./", // Redirect output structure to the directory.
18 | // "rootDir": "./", // Specify the root directory of input files. Use to control the output directory structure with --outDir.
19 | // "removeComments": true, // Do not emit comments to output.
20 | "noEmit": true, // Do not emit outputs.
21 | // "importHelpers": true, // Import emit helpers from 'tslib'.
22 | // "downlevelIteration": true, // Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'.
23 | "isolatedModules": true, // Transpile each file as a separate module (similar to 'ts.transpileModule').
24 |
25 | // Strict Type-Checking Options
26 | "strict": true, // Enable all strict type-checking options.
27 | // "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type.
28 | "strictNullChecks": true, // Enable strict null checks.
29 | // "noImplicitThis": true, // Raise error on 'this' expressions with an implied 'any' type.
30 | // "alwaysStrict": true, // Parse in strict mode and emit "use strict" for each source file.
31 |
32 | // Additional Checks
33 | "noUnusedLocals": true, // Report errors on unused locals.
34 | // "noUnusedParameters": true, // Report errors on unused parameters.
35 | "noImplicitReturns": true, // Report error when not all code paths in function return a value.
36 | // "noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement.
37 |
38 | // Module Resolution Options
39 | "moduleResolution": "node", // Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6).
40 | "esModuleInterop": true,
41 | "baseUrl": ".", // Base directory to resolve non-absolute module names.
42 | "paths": {
43 | "src/*": ["src/*"]
44 | // "~//": ["src/views"],
45 | // "@components//": ["src/components//"]
46 | }, // A series of entries which re-map imports to lookup locations relative to the 'baseUrl'.
47 | // "rootDirs": [] // List of root folders whose combined content represents the structure of the project at runtime.
48 | // "typeRoots": [] // List of folders to include type definitions from.
49 | // "typeRoots": ["src/types", "node_modules/@types"],
50 | "types": ["vite/client", "jest"], // Type declaration files to be included in compilation.
51 | // "allowSyntheticDefaultImports": true // Allow default imports from modules with no default export. This does not affect code emit, just typechecking.
52 | // ! https://github.com/pnpm/pnpm/issues/3671
53 | "preserveSymlinks": true, // Do not resolve the real path of symlinks.
54 |
55 | // Source Map Options
56 | // "sourceRoot": "./", // Specify the location where debugger should locate TypeScript files instead of source locations.
57 | // "mapRoot": "./", // Specify the location where debugger should locate map files instead of generated locations.
58 | // "inlineSourceMap": true, // Emit a single file with source maps instead of having a separate file.
59 | // "inlineSources": true, // Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set.
60 |
61 | // Experimental Options
62 | // "experimentalDecorators": true, // Enables experimental support for ES7 decorators.
63 | // "emitDecoratorMetadata": true, // Enables experimental support for emitting type metadata for decorators.
64 |
65 | // Advanced Options
66 | "skipLibCheck": true,
67 | "incremental": false,
68 | "resolveJsonModule": true,
69 | "forceConsistentCasingInFileNames": true
70 | // ! https://preactjs.com/guide/v10/typescript/
71 | // "jsx": "react-jsx",
72 | // "jsxImportSource": "preact",
73 | },
74 | "exclude": ["extension", "node_modules", ".idea"],
75 | "include": ["**/*.ts", "**/*.tsx"]
76 | // "include": ["**/*.ts", "**/*.tsx", "**/*.js"]
77 | // "include": ["src/**/*", "tests/**/*"]
78 | }
79 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "jsxImportSource": "preact"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/vite.config.content.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
2 | import { defineConfig } from 'vite';
3 |
4 | import { r, isDev } from './scripts/utils';
5 | import { sharedConfig } from './vite.config';
6 |
7 | // bundling the content script using Vite
8 | export default defineConfig({
9 | ...sharedConfig,
10 | build: {
11 | watch: isDev
12 | ? {
13 | include: [r('src/webext/**/*'), r('src/components/**/*')],
14 | }
15 | : undefined,
16 | outDir: r('extension/dist'),
17 | cssCodeSplit: false,
18 | minify: 'terser',
19 | brotliSize: false,
20 | emptyOutDir: false,
21 | sourcemap: isDev ? 'inline' : false,
22 | lib: {
23 | entry: r('src/webext/content_scripts.ts'),
24 | formats: ['es'],
25 | },
26 | rollupOptions: {
27 | output: {
28 | entryFileNames: 'content_scripts.global.js',
29 | },
30 | },
31 | },
32 | plugins: [...sharedConfig.plugins!],
33 | });
34 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
3 | import { dirname, relative } from 'path';
4 |
5 | import preact from '@preact/preset-vite';
6 | import { visualizer } from 'rollup-plugin-visualizer';
7 | import type { UserConfig } from 'vite';
8 | import { defineConfig } from 'vite';
9 | // import copy from 'rollup-plugin-copy';
10 |
11 | import { isDev, port, r } from './scripts/utils';
12 |
13 | export const sharedConfig: UserConfig = {
14 | root: r('src'),
15 | resolve: {
16 | alias: {
17 | 'src/': `${r('src')}/`,
18 | },
19 | },
20 | define: {
21 | __DEV__: isDev,
22 | },
23 | plugins: [
24 | // ! preact https://github.com/preactjs/preset-vite
25 | preact(),
26 | // ! rewrite assets to use relative path
27 | {
28 | name: 'assets-rewrite',
29 | enforce: 'post',
30 | apply: 'build',
31 | transformIndexHtml(html, { path }) {
32 | return html.replace(
33 | /"\/assets\//g,
34 | `"${relative(dirname(path), '/assets')}/`.replace(/\\/g, '/'), // ! replace backslash to slash
35 | );
36 | },
37 | },
38 | ],
39 | optimizeDeps: {
40 | include: ['preact', 'webextension-polyfill'],
41 | },
42 | };
43 |
44 | export default defineConfig(({ command }) => {
45 | return {
46 | ...sharedConfig,
47 | base: command === 'serve' ? `http://localhost:${port}/` : undefined,
48 | server: {
49 | port,
50 | hmr: {
51 | host: 'localhost',
52 | },
53 | },
54 | build: {
55 | outDir: r('extension/dist'),
56 | emptyOutDir: false,
57 | sourcemap: isDev ? 'inline' : false,
58 | terserOptions: {
59 | mangle: false,
60 | },
61 | cssCodeSplit: false, // ! If false, all CSS in the entire project will be extracted into a single CSS file.
62 | minify: 'terser',
63 | brotliSize: false, // ! compression size report
64 | rollupOptions: {
65 | input: {
66 | app: r('src/views/app/index.html'),
67 | popup: r('src/views/popup/index.html'),
68 | // background: r('src/views/background/index.html'),
69 | // options: r('src/views/options/index.html'),
70 | // newTab: r('src/views/new-tab/index.html'),
71 | },
72 | output: {
73 | manualChunks: {
74 | // ! manually bundle @mui for Popup to reduce size
75 | 'mui-for-popup': [
76 | '@mui/material/Checkbox',
77 | '@mui/material/Rating',
78 | '@mui/material/Box',
79 | '@mui/material/Button',
80 | '@mui/material/CssBaseline',
81 | '@mui/material/Alert',
82 | '@mui/material/AlertTitle',
83 | '@mui/material/AppBar',
84 | '@mui/material/Button',
85 | '@mui/material/Toolbar',
86 | '@mui/material/Typography',
87 | '@mui/material/CircularProgress',
88 | ],
89 | },
90 | },
91 | plugins: [
92 | visualizer({
93 | filename: 'build/stats.html',
94 | gzipSize: true,
95 | brotliSize: true,
96 | }),
97 | ],
98 | },
99 | },
100 | plugins: [...sharedConfig.plugins!],
101 | };
102 | });
103 |
104 | // copy({
105 | // targets: [
106 | // {
107 | // src: ['./node_modules/@mozilla/readability'],
108 | // dest: './extension/dist/',
109 | // },
110 | // ],
111 | // verbose: true,
112 | // copyOnce: true,
113 | // hook: 'writeBundle',
114 | // }),
115 |
--------------------------------------------------------------------------------