├── .github └── workflows │ └── ci.yml ├── .gitignore ├── COPYING ├── Dockerfile ├── README.md ├── client ├── .eslintrc.json ├── index.html ├── package.json ├── public │ ├── icon.svg │ └── logo192.png ├── src │ ├── App.js │ ├── AppContext.js │ ├── DrawerContent.js │ ├── components │ │ ├── Anki │ │ │ ├── AnkiContentMobile.js │ │ │ ├── AnkiContext.js │ │ │ ├── AnkiInput.js │ │ │ ├── AppBarDesktop.js │ │ │ ├── AppBarMobile.js │ │ │ ├── ArticleView.js │ │ │ ├── Dictionaries.js │ │ │ ├── DictionariesButton.js │ │ │ ├── DictionariesDialogue.js │ │ │ ├── DictionariesPane.js │ │ │ └── WordList.js │ │ ├── AnkiScreen.js │ │ ├── FtsScreen.js │ │ ├── FullTextSearch │ │ │ ├── AppBarDesktop.js │ │ │ ├── AppBarMobile.js │ │ │ ├── ArticleView.js │ │ │ ├── Articles.js │ │ │ ├── ArticlesButton.js │ │ │ ├── ArticlesDialogue.js │ │ │ ├── ArticlesPane.js │ │ │ ├── FtsContext.js │ │ │ └── FtsInput.js │ │ ├── Library │ │ │ ├── Appbar.js │ │ │ ├── Dictionaries │ │ │ │ ├── AddDictionaryDialogue.js │ │ │ │ ├── DeleteDictionaryDialogue.js │ │ │ │ ├── DictionariesList.js │ │ │ │ ├── DictionariesTable.js │ │ │ │ ├── RenameDictionaryDialogue.js │ │ │ │ └── utils.js │ │ │ ├── DictionariesTab.js │ │ │ ├── Groups │ │ │ │ ├── AddGroupDialogue.js │ │ │ │ ├── DeleteDialogue.js │ │ │ │ ├── EditDictionariesDialogue.js │ │ │ │ ├── EditLanguageDialogue.js │ │ │ │ ├── GroupCard.js │ │ │ │ ├── GroupsCards.js │ │ │ │ ├── GroupsTable.js │ │ │ │ └── RenameDialogue.js │ │ │ ├── GroupsTab.js │ │ │ ├── Sources │ │ │ │ ├── AddSourceDialogue.js │ │ │ │ └── DeleteDialogue.js │ │ │ └── SourcesTab.js │ │ ├── LibraryScreen.js │ │ ├── Query │ │ │ ├── AppBarDesktop.js │ │ │ ├── AppBarMobile.js │ │ │ ├── ArticleView.js │ │ │ ├── Dictionaries.js │ │ │ ├── DictionariesButton.js │ │ │ ├── DictionariesDialogue.js │ │ │ ├── DictionariesPane.js │ │ │ ├── QueryContentMobile.js │ │ │ ├── QueryContext.js │ │ │ ├── QueryInput.js │ │ │ └── WordList.js │ │ ├── QueryScreen.js │ │ ├── Settings │ │ │ ├── Appbar.js │ │ │ ├── ClearHistoryDialogue.js │ │ │ ├── HistorySizeDialogue.js │ │ │ └── SuggestionSizeDialogue.js │ │ ├── SettingsScreen.js │ │ └── common │ │ │ ├── ConfirmationDialogue.js │ │ │ ├── DeleteButton.js │ │ │ ├── EditButton.js │ │ │ ├── FormDialogue.js │ │ │ ├── LoadingDialogue.js │ │ │ ├── MenuButton.js │ │ │ └── ProgressDialogue.js │ ├── config.js │ ├── l10n.js │ ├── main.js │ ├── theme.js │ ├── translations │ │ ├── ar-SA.json │ │ ├── da-DK.json │ │ ├── de-DE.json │ │ ├── el-GR.json │ │ ├── en-GB.json │ │ ├── en-US.json │ │ ├── es-ES.json │ │ ├── fr-FR.json │ │ ├── he-IL.json │ │ ├── hi-IN.json │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ ├── ko-KR.json │ │ ├── nl-NL.json │ │ ├── no-NO.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── ru-RU.json │ │ ├── sv-SE.json │ │ ├── tr-TR.json │ │ ├── uk-UA.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ └── utils.js ├── vite.config.js └── yarn.lock ├── crowdin.yml ├── docker-compose.yml ├── docs ├── css │ └── style.css ├── img │ ├── dark.png │ ├── favicon.ico │ ├── light1.png │ ├── light2.png │ └── mobile.png ├── index.html └── installation.html ├── nginx.conf ├── pyproject.toml ├── server ├── app │ ├── Ngram_README.txt │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── lookup.py │ │ ├── management.py │ │ └── validators.py │ ├── db_manager.py │ ├── dictionaries.py │ ├── dicts │ │ ├── __init__.py │ │ ├── base_reader.py │ │ ├── dsl │ │ │ ├── __init__.py │ │ │ ├── layer.py │ │ │ ├── main.py │ │ │ ├── markup_converter.py │ │ │ └── tag.py │ │ ├── dsl_reader.py │ │ ├── mdict │ │ │ ├── __init__.py │ │ │ ├── html_cleaner.py │ │ │ ├── lzo.py │ │ │ ├── pureSalsa20.py │ │ │ ├── readmdict.py │ │ │ └── ripemd128.py │ │ ├── mdict_reader.py │ │ ├── stardict │ │ │ ├── __init__.py │ │ │ ├── html_cleaner.py │ │ │ ├── interfaces.py │ │ │ ├── lxml_types.py │ │ │ ├── stardict.py │ │ │ ├── xdxf.xsl │ │ │ ├── xdxf_cleaner.py │ │ │ └── xdxf_transform.py │ │ └── stardict_reader.py │ ├── langs │ │ ├── __init__.py │ │ ├── arabic.py │ │ ├── chinese.py │ │ └── greek.py │ ├── settings.py │ ├── templates │ │ ├── anki.html │ │ ├── anki_standalone.html │ │ ├── articles.html │ │ ├── articles_standalone.html │ │ └── suggestions.html │ └── transformation │ │ ├── __init__.py │ │ ├── harrap.py │ │ ├── michaelis.py │ │ └── oxford_hachette.py ├── requirements.txt ├── server.py └── updater.py ├── silverdict.service └── termux_setup.sh /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | docker: 12 | runs-on: ${{ fromJSON('["ubuntu-latest", "self-hosted"]')[github.repository == 'github/docs-internal'] }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@v3 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | - name: Login to DockerHub 21 | uses: docker/login-action@v3 22 | with: 23 | username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | password: ${{ secrets.DOCKERHUB_TOKEN }} 25 | - name: Build and push 26 | uses: docker/build-push-action@v5 27 | with: 28 | context: . 29 | file: ./Dockerfile 30 | build-args: | 31 | ENABLE_FULL_TEXT_SEARCH=true 32 | ENABLE_MORPHOLOGY_ANALYSIS=true 33 | ENABLE_CHINESE_CONVERSION=true 34 | platforms: linux/amd64, linux/arm64 35 | push: true 36 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/silverdict:latest 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | *.pyc 5 | clean_up_pyc.py 6 | *.zip 7 | /.pnp 8 | .pnp.js 9 | *.bat 10 | release 11 | make_release.py 12 | 13 | # local env files 14 | .env.local 15 | .env.*.local 16 | 17 | # Log files 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | pnpm-debug.log* 22 | 23 | # Editor directories and files 24 | .idea 25 | .vscode 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Frontend Builder 2 | FROM node:lts-alpine3.19 as frontend-builder 3 | 4 | WORKDIR /silverdict/client 5 | 6 | # Copy only package.json and yarn.lock to leverage Docker cache layer 7 | COPY ./client/package.json ./client/yarn.lock ./ 8 | RUN yarn install --frozen-lockfile 9 | 10 | # Copy the entire client directory 11 | COPY client ./ 12 | 13 | # Build the frontend 14 | RUN yarn build 15 | 16 | # Stage 2: Production Environment 17 | FROM alpine:3.19 18 | 19 | ARG VERSION="1.2.2" 20 | ARG ENABLE_FULL_TEXT_SEARCH="" 21 | ARG ENABLE_MORPHOLOGY_ANALYSIS="" 22 | ARG ENABLE_CHINESE_CONVERSION="" 23 | 24 | ENV HOST="0.0.0.0" 25 | ENV PORT="2628" 26 | 27 | ENV PYTHONUNBUFFERED=1 28 | ENV VIRTUAL_ENV=/opt/venv 29 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 30 | 31 | LABEL org.opencontainers.image.title="SilverDict" 32 | LABEL org.opencontainers.image.description="Web-Based Alternative to GoldenDict" 33 | LABEL org.opencontainers.image.version="${VERSION}" 34 | LABEL org.opencontainers.image.authors="Yi Xing " 35 | LABEL org.opencontainers.image.url="https://crissium.github.io/SilverDict" 36 | LABEL org.opencontainers.image.source="https://github.com/Crissium/SilverDict" 37 | LABEL org.opencontainers.image.licenses="GPL-3.0-or-later" 38 | 39 | 40 | WORKDIR /silverdict/server 41 | 42 | COPY ./server/requirements.txt /silverdict/server/requirements.txt 43 | 44 | # Install dependencies 45 | RUN apk update && \ 46 | apk add --no-cache python3 lzo py3-yaml py3-xxhash py3-flask py3-flask-cors py3-waitress py3-requests ${ENABLE_FULL_TEXT_SEARCH:+xapian-bindings-python3 py3-lxml} ${ENABLE_MORPHOLOGY_ANALYSIS:+libhunspell} && \ 47 | apk add --no-cache --virtual .build-deps python3-dev py3-pip gcc g++ lzo-dev ${ENABLE_MORPHOLOGY_ANALYSIS:+hunspell-dev} ${ENABLE_CHINESE_CONVERSION:+make cmake doxygen} && \ 48 | python3 -m venv $VIRTUAL_ENV --system-site-packages && \ 49 | pip install --no-cache-dir -r requirements.txt && \ 50 | if [ "x$ENABLE_MORPHOLOGY_ANALYSIS" = "xtrue" ]; then \ 51 | ln -s /usr/include/hunspell/* /usr/include/ && \ 52 | ln -s /usr/lib/libhunspell-*.so /usr/lib/libhunspell.so && \ 53 | pip install hunspell; \ 54 | fi && \ 55 | if [ "x$ENABLE_CHINESE_CONVERSION" = "xtrue" ]; then \ 56 | wget https://github.com/BYVoid/OpenCC/archive/refs/tags/ver.1.1.7.tar.gz && \ 57 | tar xzf ver.1.1.7.tar.gz && \ 58 | cd OpenCC-ver.1.1.7 && \ 59 | pip install wheel && \ 60 | make python-build && \ 61 | make python-install && \ 62 | pip uninstall -y wheel && \ 63 | cd .. && \ 64 | rm -rf OpenCC-ver.1.1.7 ver.1.1.7.tar.gz; \ 65 | fi && \ 66 | apk del .build-deps 67 | 68 | # Copy server and built frontend from the frontend-builder stage 69 | COPY ./server /silverdict/server 70 | COPY --from=frontend-builder /silverdict/client/build /silverdict/server/build 71 | 72 | # Expose the required port 73 | EXPOSE "${PORT}/tcp" 74 | 75 | # Entry point for the application 76 | ENTRYPOINT python server.py "${HOST}:${PORT}" 77 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaFeatures": { 13 | "jsx": true 14 | }, 15 | "ecmaVersion": 14, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react-hooks" 20 | ], 21 | "rules": { 22 | "indent": [ 23 | "error", 24 | "tab" 25 | ], 26 | "linebreak-style": [ 27 | "error", 28 | "unix" 29 | ], 30 | "quotes": [ 31 | "error", 32 | "single" 33 | ], 34 | "semi": [ 35 | "error", 36 | "always" 37 | ], 38 | "no-unused-vars": "off", 39 | "no-inner-declarations": "off" 40 | } 41 | } -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | SilverDict 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "silverdict", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.11.3", 13 | "@emotion/styled": "^11.11.0", 14 | "@fontsource/roboto": "^5.0.8", 15 | "@mui/icons-material": "^5.15.7", 16 | "@mui/material": "^5.15.7", 17 | "iso-639-1": "^3.1.0", 18 | "react": "^18.2.0", 19 | "react-beautiful-dnd": "^13.1.1", 20 | "react-dom": "^18.2.0", 21 | "react-localization": "^1.0.19", 22 | "react-router-dom": "^6.22.0", 23 | "rollup": "npm:@rollup/wasm-node" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.43", 27 | "@types/react-beautiful-dnd": "^13.1.8", 28 | "@types/react-dom": "^18.2.17", 29 | "@vitejs/plugin-react": "^4.2.1", 30 | "vite": "^5.0.8" 31 | }, 32 | "resolutions": { 33 | "rollup": "npm:@rollup/wasm-node" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crissium/SilverDict/9413a3f752d61690a3d761cbbf7a438ecd838edf/client/public/logo192.png -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import DrawerContent from './DrawerContent'; 4 | import QueryScreen from './components/QueryScreen'; 5 | import AnkiScreen from './components/AnkiScreen'; 6 | import FtsScreen from './components/FtsScreen'; 7 | import LibraryScreen from './components/LibraryScreen'; 8 | import DictionariesTab from './components/Library/DictionariesTab'; 9 | import GroupsTab from './components/Library/GroupsTab'; 10 | import SourcesTab from './components/Library/SourcesTab'; 11 | import SettingsScreen from './components/SettingsScreen'; 12 | import { AppProvider } from './AppContext'; 13 | 14 | export default function App() { 15 | return ( 16 | 17 | 18 | } /> 19 | } /> 20 | } /> 21 | }> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | 27 | } /> 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/AppContext.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from 'react'; 2 | import { API_PREFIX } from './config'; 3 | import { dictionarySnake2Camel, loadJson } from './utils'; 4 | import { localisedStrings } from './l10n'; 5 | 6 | const AppContext = createContext(); 7 | 8 | export function AppProvider({ children }) { 9 | const [dictionaries, setDictionaries] = useState([]); 10 | const [groups, setGroups] = useState([]); 11 | const [groupings, setGroupings] = useState({}); 12 | 13 | const [history, setHistory] = useState([]); 14 | 15 | const [sizeSuggestion, setSizeSuggestion] = useState(10); 16 | const [sizeHistory, setSizeHistory] = useState(100); 17 | 18 | const [formats, setFormats] = useState([]); 19 | const [sources, setSources] = useState([]); 20 | 21 | const [drawerOpened, setDrawerOpened] = useState(false); 22 | 23 | async function initialise( 24 | setDictionaries, 25 | setGroups, 26 | setGroupings, 27 | setHistory, 28 | setSizeHistory, 29 | setSizeSuggestion, 30 | setFormats, 31 | setSources 32 | ) { 33 | try { 34 | const [ 35 | dictionariesData, 36 | groupsData, 37 | groupingsData, 38 | historyData, 39 | sizeHistoryData, 40 | sizeSuggestionData, 41 | formatsData, 42 | sourcesData 43 | ] = await Promise.all([ 44 | fetch(`${API_PREFIX}/management/dictionaries`).then(loadJson), 45 | fetch(`${API_PREFIX}/management/groups`).then(loadJson), 46 | fetch(`${API_PREFIX}/management/dictionary_groupings`).then(loadJson), 47 | fetch(`${API_PREFIX}/management/history`).then(loadJson), 48 | fetch(`${API_PREFIX}/management/history_size`).then(loadJson), 49 | fetch(`${API_PREFIX}/management/num_suggestions`).then(loadJson), 50 | fetch(`${API_PREFIX}/management/formats`).then(loadJson), 51 | fetch(`${API_PREFIX}/management/sources`).then(loadJson) 52 | ]); 53 | 54 | setDictionaries(dictionariesData.map(dictionarySnake2Camel)); 55 | setGroups(groupsData); 56 | setGroupings(groupingsData); 57 | setHistory(historyData); 58 | setSizeHistory(sizeHistoryData['size']); 59 | setSizeSuggestion(sizeSuggestionData['size']); 60 | setFormats(formatsData); 61 | setSources(sourcesData); 62 | } catch (error) { 63 | alert(localisedStrings['app-context-message-failure-initialising'] + '\n' + error); 64 | } 65 | } 66 | 67 | useEffect(function () { 68 | initialise( 69 | setDictionaries, 70 | setGroups, 71 | setGroupings, 72 | setHistory, 73 | setSizeHistory, 74 | setSizeSuggestion, 75 | setFormats, 76 | setSources 77 | ); 78 | }, []); 79 | 80 | return ( 81 | 104 | {children} 105 | 106 | ); 107 | } 108 | 109 | export function useAppContext() { 110 | return useContext(AppContext); 111 | } 112 | -------------------------------------------------------------------------------- /client/src/DrawerContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Drawer from '@mui/material/Drawer'; 4 | import MuiLink from '@mui/material/Link'; 5 | import List from '@mui/material/List'; 6 | import ListItem from '@mui/material/ListItem'; 7 | import ListItemIcon from '@mui/material/ListItemIcon'; 8 | import ListItemButton from '@mui/material/ListItemButton'; 9 | import ListItemText from '@mui/material/ListItemText'; 10 | import Typography from '@mui/material/Typography'; 11 | import SearchIcon from '@mui/icons-material/Search'; 12 | import StarIcon from '@mui/icons-material/Star'; 13 | import ManageSearchIcon from '@mui/icons-material/ManageSearch'; 14 | import CollectionsBookmarkIcon from '@mui/icons-material/CollectionsBookmark'; 15 | import SettingsIcon from '@mui/icons-material/Settings'; 16 | import { Link, useLocation } from 'react-router-dom'; 17 | import { useAppContext } from './AppContext'; 18 | import { localisedStrings } from './l10n'; 19 | 20 | function DrawerItem(props) { 21 | const icons = { 22 | '/': , 23 | '/anki': , 24 | '/fts': , 25 | '/library/dictionaries': , 26 | '/settings': 27 | }; 28 | 29 | const { index, route, title, isActive } = props; 30 | const { setDrawerOpened } = useAppContext(); 31 | 32 | return ( 33 | 34 | setDrawerOpened(false)} 39 | > 40 | 41 | {icons[route]} 42 | 43 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export default function DrawerContent() { 57 | const { drawerOpened, setDrawerOpened } = useAppContext(); 58 | const routes = [ 59 | { 60 | route: '/', 61 | title: localisedStrings['query-screen-title'] 62 | }, 63 | { 64 | route: '/anki', 65 | title: localisedStrings['anki-screen-title'] 66 | }, 67 | { 68 | route: '/fts', 69 | title: localisedStrings['full-text-search-screen-title'] 70 | }, 71 | { 72 | route: '/library/dictionaries', 73 | title: localisedStrings['library-screen-title'] 74 | }, 75 | { 76 | route: '/settings', 77 | title: localisedStrings['settings-screen-title'] 78 | } 79 | ]; 80 | const location = useLocation(); 81 | 82 | return ( 83 | setDrawerOpened(false)} 87 | > 88 | 89 | {routes.map((route, index) => ( 90 | 96 | ))} 97 | 98 | 105 | 106 | 112 | SilverDict 113 | 114 | 118 | v1.2.2 119 | 120 | 121 | 122 | 123 | © Yi Xing 2024 124 | 125 | 126 | 127 | 128 | {localisedStrings['licence']} 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /client/src/components/Anki/AnkiContentMobile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import WordList from './WordList'; 4 | import ArticleView from './ArticleView'; 5 | import { useAnkiContext } from './AnkiContext'; 6 | 7 | export default function AnkiContentMobile() { 8 | const { showingArticle, ankiContentRef } = useAnkiContext(); 9 | 10 | return ( 11 | 17 | {showingArticle ? : } 18 | 19 | ); 20 | } -------------------------------------------------------------------------------- /client/src/components/Anki/AnkiContext.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; 2 | import { API_PREFIX } from '../../config'; 3 | import { loadJson } from '../../utils'; 4 | import { useAppContext } from '../../AppContext'; 5 | import { localisedStrings } from '../../l10n'; 6 | 7 | const AnkiContext = createContext(); 8 | 9 | export function AnkiProvider({ children }) { 10 | const { dictionaries, groups, groupings, sizeSuggestion } = useAppContext(); 11 | 12 | const [searchTerm, setSearchTerm] = useState(''); 13 | 14 | const inputRef = useRef(null); 15 | 16 | const [suggestionTimestamp, setSuggestionTimestamp] = useState(0); 17 | const [suggestions, setSuggestions] = useState([]); 18 | 19 | const [nameActiveGroup, setNameActiveGroup] = useState('Default Group'); 20 | const [namesActiveDictionaries, setNamesActiveDictionaries] = useState([]); 21 | 22 | const [article, setArticle] = useState(''); 23 | 24 | const articleViewRef = useRef(null); 25 | 26 | // On mobile only 27 | const [dictionariesDialogueOpened, setDictionariesDialogueOpened] = useState(false); 28 | const [showingArticle, setShowingArticle] = useState(false); 29 | const ankiContentRef = useRef(null); 30 | 31 | useEffect(function () { 32 | setNameActiveGroup('Default Group'); 33 | }, [groups.length]); 34 | 35 | function resetNamesActiveDictionaries() { 36 | if (groupings[nameActiveGroup]) { 37 | const dictionariesInGroup = []; 38 | for (let dictionary of dictionaries) { 39 | if (groupings[nameActiveGroup].includes(dictionary.name)) { 40 | dictionariesInGroup.push(dictionary.name); 41 | } 42 | } 43 | setNamesActiveDictionaries(dictionariesInGroup); 44 | } 45 | } 46 | 47 | useEffect(function () { 48 | if (searchTerm.length === 0) { 49 | setSuggestionTimestamp(Date.now()); 50 | setSuggestions(['']); 51 | resetNamesActiveDictionaries(); 52 | } else { 53 | fetch(`${API_PREFIX}/suggestions/${nameActiveGroup}/${encodeURIComponent(searchTerm)}?timestamp=${Date.now()}`) 54 | .then(loadJson) 55 | .then((data) => { 56 | if (data['timestamp'] > suggestionTimestamp) { 57 | setSuggestionTimestamp(data['timestamp']); 58 | // Filter out empty suggestions 59 | const newSuggestions = data['suggestions'].filter((suggestion) => suggestion.length > 0); 60 | if (newSuggestions.length > 0) { 61 | setSuggestions(newSuggestions); 62 | } else { 63 | setSuggestions(['']); 64 | } 65 | } 66 | }) 67 | .catch((error) => { 68 | alert(localisedStrings['failure-fetching-suggestions'] + '\n' + error); 69 | }); 70 | } 71 | }, [dictionaries, groupings, nameActiveGroup, searchTerm, sizeSuggestion]); 72 | 73 | const search = useCallback(function (newSearch) { 74 | if (newSearch.length === 0) { 75 | return; 76 | } 77 | 78 | try { 79 | newSearch = decodeURIComponent(newSearch); 80 | newSearch = encodeURIComponent(newSearch); 81 | } 82 | catch (error) { 83 | newSearch = encodeURIComponent(newSearch); 84 | } 85 | 86 | fetch(`${API_PREFIX}/anki/${nameActiveGroup}/${newSearch}?dicts=true`) 87 | .then(loadJson) 88 | .then((data) => { 89 | const html = data['articles']; 90 | setNamesActiveDictionaries(data['dictionaries']); 91 | 92 | // Fix an error where the dynamically loaded script is not executed 93 | const scriptSrcMatches = [...html.matchAll(/ match[1]); 95 | if (scriptSrcs.length !== 0) { 96 | scriptSrcs.forEach((src) => { 97 | const script = document.createElement('script'); 98 | script.src = src; 99 | document.body.appendChild(script); 100 | }); 101 | } 102 | return html; 103 | }) 104 | .then((html) => { 105 | setArticle(html); 106 | }) 107 | .then(() => { 108 | // Scroll to top of article 109 | if (articleViewRef.current) { 110 | // On the mobile interface, this will fail because the view hasn't been mounted yet 111 | articleViewRef.current.scrollIntoView({ block: 'start' }); 112 | } 113 | }) 114 | .catch((error) => { 115 | resetNamesActiveDictionaries(); 116 | alert(localisedStrings['failure-fetching-articles'] + '\n' + error); 117 | }); 118 | }, [nameActiveGroup]); 119 | 120 | useEffect(function () { 121 | search(searchTerm); 122 | if (article.length !== 0) { 123 | setShowingArticle(true); 124 | } 125 | }, [search]); 126 | 127 | return ( 128 | 149 | {children} 150 | 151 | ); 152 | } 153 | 154 | export function useAnkiContext() { 155 | return useContext(AnkiContext); 156 | } 157 | -------------------------------------------------------------------------------- /client/src/components/Anki/AnkiInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Input from '@mui/material/Input'; 3 | import IconButton from '@mui/material/IconButton'; 4 | import ClearIcon from '@mui/icons-material/Clear'; 5 | import InputAdornment from '@mui/material/InputAdornment'; 6 | import useMediaQuery from '@mui/material/useMediaQuery'; 7 | import { useAnkiContext } from './AnkiContext'; 8 | import { localisedStrings } from '../../l10n'; 9 | import { IS_DESKTOP_MEDIA_QUERY } from '../../utils'; 10 | 11 | export default function AnkiInput() { 12 | const { searchTerm, setSearchTerm, inputRef, setShowingArticle, ankiContentRef } = useAnkiContext(); 13 | const isDesktop = useMediaQuery(IS_DESKTOP_MEDIA_QUERY); 14 | 15 | function handleSearchTermChange(e) { 16 | setSearchTerm(e.target.value); 17 | } 18 | 19 | function handleFocus(e) { 20 | e.target.select(); 21 | setShowingArticle(false); 22 | if (!isDesktop && ankiContentRef.current) { 23 | ankiContentRef.current.scrollTop = 0; 24 | } 25 | } 26 | 27 | function handleClick() { 28 | // On click, give the input focus, because clicking on the input but not the text doesn't 29 | if (inputRef.current) { 30 | inputRef.current.focus(); 31 | } 32 | } 33 | 34 | function handleClear() { 35 | setSearchTerm(''); 36 | if (inputRef.current) { 37 | inputRef.current.focus(); // Refocus the input after clearing 38 | } 39 | } 40 | 41 | return ( 42 | 65 | 69 | 70 | 71 | 72 | ) 73 | } 74 | /> 75 | ); 76 | } -------------------------------------------------------------------------------- /client/src/components/Anki/AppBarDesktop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Toolbar from '@mui/material/Toolbar'; 4 | import Typography from '@mui/material/Typography'; 5 | import MenuButton from '../common/MenuButton'; 6 | import { localisedStrings } from '../../l10n'; 7 | 8 | export default function AppBarDesktop() { 9 | return ( 10 | 11 | 12 | 13 | 14 | {localisedStrings['anki-screen-title']} 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /client/src/components/Anki/AppBarMobile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Toolbar from '@mui/material/Toolbar'; 4 | import MenuButton from '../common/MenuButton'; 5 | import AnkiInput from './AnkiInput'; 6 | import DictionariesButton from './DictionariesButton'; 7 | 8 | export default function AppBarMobile() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /client/src/components/Anki/ArticleView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useMediaQuery from '@mui/material/useMediaQuery'; 3 | import { useAnkiContext } from './AnkiContext'; 4 | import { IS_DESKTOP_MEDIA_QUERY } from '../../utils'; 5 | 6 | export default function ArticleView() { 7 | const { article, articleViewRef } = useAnkiContext(); 8 | const isDesktop = useMediaQuery(IS_DESKTOP_MEDIA_QUERY); 9 | 10 | return ( 11 | <> 12 | 31 |
38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /client/src/components/Anki/Dictionaries.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import List from '@mui/material/List'; 3 | import ListItem from '@mui/material/ListItem'; 4 | import ListItemButton from '@mui/material/ListItemButton'; 5 | import ListItemText from '@mui/material/ListItemText'; 6 | import Snackbar from '@mui/material/Snackbar'; 7 | import MenuItem from '@mui/material/MenuItem'; 8 | import FormControl from '@mui/material/FormControl'; 9 | import Select from '@mui/material/Select'; 10 | import { useAppContext } from '../../AppContext'; 11 | import { useAnkiContext } from './AnkiContext'; 12 | import { isRTL } from '../../utils'; 13 | import { localisedStrings } from '../../l10n'; 14 | 15 | function DictionaryListItem(props) { 16 | const { name, displayName } = props; 17 | const { setDictionariesDialogueOpened } = useAnkiContext(); 18 | const [messageOpened, setMessageOpened] = useState(false); 19 | 20 | function navigateToDictAndCopy(name) { 21 | setDictionariesDialogueOpened(false); 22 | const dict = document.getElementById(name); 23 | if (dict) { 24 | dict.scrollIntoView(); 25 | 26 | navigator.clipboard.writeText(dict.innerHTML) 27 | .then(() => { 28 | setMessageOpened(true); 29 | }) 30 | .catch((error) => { 31 | alert(localisedStrings['anki-failure-copying'] + '\n' + error); 32 | }); 33 | } 34 | } 35 | 36 | return ( 37 | <> 38 | 39 | navigateToDictAndCopy(name)}> 40 | 46 | 47 | 48 | setMessageOpened(false)} 51 | autoHideDuration={2000} 52 | message={localisedStrings['anki-dictionary-content-copied-message']} 53 | /> 54 | 55 | ); 56 | } 57 | 58 | export default function Dictionaries() { 59 | const { dictionaries, groups, groupings } = useAppContext(); 60 | const { nameActiveGroup, setNameActiveGroup, namesActiveDictionaries, setDictionariesDialogueOpened } = useAnkiContext(); 61 | 62 | function handleGroupChange(e) { 63 | setNameActiveGroup(e.target.value); 64 | setDictionariesDialogueOpened(false); 65 | } 66 | 67 | if (groupings && groupings[nameActiveGroup] && namesActiveDictionaries) 68 | return ( 69 | <> 70 | 74 | 89 | 90 | 93 | {dictionaries.map((dict) => { 94 | if (groupings[nameActiveGroup].includes(dict.name) && 95 | namesActiveDictionaries.includes(dict.name)) 96 | return ( 97 | 101 | ); 102 | else 103 | return null; 104 | })} 105 | 106 | 107 | ); 108 | else 109 | return ( 110 | 111 | {dictionaries.map((dict) => ( 112 | 116 | ))} 117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /client/src/components/Anki/DictionariesButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconButton from '@mui/material/IconButton'; 3 | import CollectionsBookmarkIcon from '@mui/icons-material/CollectionsBookmark'; 4 | import { useAnkiContext } from './AnkiContext'; 5 | 6 | export default function DictionariesButton() { 7 | const { setDictionariesDialogueOpened } = useAnkiContext(); 8 | 9 | return ( 10 | setDictionariesDialogueOpened(true)} 12 | > 13 | 14 | 15 | ); 16 | } -------------------------------------------------------------------------------- /client/src/components/Anki/DictionariesDialogue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dialog from '@mui/material/Dialog'; 3 | import { useAnkiContext } from './AnkiContext'; 4 | import Dictionaries from './Dictionaries'; 5 | 6 | export default function DictionariesDialogue() { 7 | const { dictionariesDialogueOpened, setDictionariesDialogueOpened } = useAnkiContext(); 8 | 9 | return ( 10 | setDictionariesDialogueOpened(false)} 13 | fullWidth 14 | > 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/Anki/DictionariesPane.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Dictionaries from './Dictionaries'; 4 | 5 | export default function DictionariesPane() { 6 | return ( 7 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/Anki/WordList.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import List from '@mui/material/List'; 3 | import ListItem from '@mui/material/ListItem'; 4 | import ListItemButton from '@mui/material/ListItemButton'; 5 | import ListItemText from '@mui/material/ListItemText'; 6 | import { useAppContext } from '../../AppContext'; 7 | import { useAnkiContext } from './AnkiContext'; 8 | import { isRTL } from '../../utils'; 9 | 10 | function WordListItem(props) { 11 | const { index, word, selectedIndex, setSelectedIndex } = props; 12 | const isSelected = selectedIndex === index; 13 | const wordIsRTL = isRTL(word); 14 | const { search, setShowingArticle } = useAnkiContext(); 15 | 16 | function handleClick() { 17 | setSelectedIndex(index); 18 | search(word); 19 | setShowingArticle(true); 20 | } 21 | 22 | return ( 23 | 24 | 28 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export default function WordList() { 41 | const { history } = useAppContext(); 42 | const { searchTerm, inputRef, search, suggestions, setShowingArticle } = useAnkiContext(); 43 | const displayingHistory = searchTerm.length === 0; 44 | const wordsToDisplay = displayingHistory ? history : suggestions; 45 | 46 | const [selectedIndex, setSelectedIndex] = useState(0); 47 | 48 | useEffect(function() { 49 | if (selectedIndex >= wordsToDisplay.length) { 50 | setSelectedIndex(wordsToDisplay.length - 1); 51 | } 52 | }, [wordsToDisplay.length]); 53 | 54 | useEffect(function() { 55 | if (displayingHistory) { 56 | setSelectedIndex(0); 57 | } 58 | }, [displayingHistory]); 59 | 60 | const handleKeyDown = useCallback(function (e) { 61 | if (document.activeElement === inputRef.current) { 62 | if (e.key === 'ArrowDown') { 63 | e.preventDefault(); 64 | if (selectedIndex < wordsToDisplay.length) { 65 | setSelectedIndex(selectedIndex + 1); 66 | } 67 | } else if (e.key === 'ArrowUp') { 68 | e.preventDefault(); 69 | if (selectedIndex > 0) { 70 | setSelectedIndex(selectedIndex - 1); 71 | } 72 | } else if (e.key === 'Enter') { 73 | search(wordsToDisplay[selectedIndex]); 74 | setShowingArticle(true); 75 | } 76 | } 77 | }, [selectedIndex, setSelectedIndex, displayingHistory, wordsToDisplay]); 78 | 79 | useEffect(function() { 80 | document.addEventListener('keydown', handleKeyDown); 81 | 82 | return () => { 83 | document.removeEventListener('keydown', handleKeyDown); 84 | }; 85 | }, [handleKeyDown]); 86 | 87 | return ( 88 | 91 | {wordsToDisplay.map((word, index) => ( 92 | 98 | ))} 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /client/src/components/AnkiScreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Stack from '@mui/material/Stack'; 3 | import Box from '@mui/material/Box'; 4 | import useMediaQuery from '@mui/material/useMediaQuery'; 5 | import { AnkiProvider } from './Anki/AnkiContext'; 6 | import ArticleView from './Anki/ArticleView'; 7 | import DictionariesPane from './Anki/DictionariesPane'; 8 | import DictionariesDialogue from './Anki/DictionariesDialogue'; 9 | import AnkiInput from './Anki/AnkiInput'; 10 | import WordList from './Anki/WordList'; 11 | import AppBarDesktop from './Anki/AppBarDesktop'; 12 | import AppBarMobile from './Anki/AppBarMobile'; 13 | import AnkiContentMobile from './Anki/AnkiContentMobile'; 14 | import { IS_DESKTOP_MEDIA_QUERY } from '../utils'; 15 | 16 | export default function AnkiScreen() { 17 | const isDesktop = useMediaQuery(IS_DESKTOP_MEDIA_QUERY); 18 | 19 | if (isDesktop) 20 | return ( 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | else 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } -------------------------------------------------------------------------------- /client/src/components/FtsScreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Stack from '@mui/material/Stack'; 4 | import useMediaQuery from '@mui/material/useMediaQuery'; 5 | import { FtsProvider } from './FullTextSearch/FtsContext'; 6 | import AppBarDesktop from './FullTextSearch/AppBarDesktop'; 7 | import AppBarMobile from './FullTextSearch/AppBarMobile'; 8 | import ArticlesPane from './FullTextSearch/ArticlesPane'; 9 | import ArticleView from './FullTextSearch/ArticleView'; 10 | import ArticlesDialogue from './FullTextSearch/ArticlesDialogue'; 11 | import { IS_DESKTOP_MEDIA_QUERY } from '../utils'; 12 | 13 | export default function FtsScreen() { 14 | const isDesktop = useMediaQuery(IS_DESKTOP_MEDIA_QUERY); 15 | 16 | if (isDesktop) 17 | return ( 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | else 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /client/src/components/FullTextSearch/AppBarDesktop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Toolbar from '@mui/material/Toolbar'; 4 | import MenuButton from '../common/MenuButton'; 5 | import FtsInput from './FtsInput'; 6 | 7 | export default function AppBarDesktop() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/FullTextSearch/AppBarMobile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Toolbar from '@mui/material/Toolbar'; 4 | import useScrollTrigger from '@mui/material/useScrollTrigger'; 5 | import Slide from '@mui/material/Slide'; 6 | import MenuButton from '../common/MenuButton'; 7 | import FtsInput from './FtsInput'; 8 | import ArticlesButton from './ArticlesButton'; 9 | import { useFtsContext } from './FtsContext'; 10 | 11 | export default function AppBarMobile() { 12 | const { queryContentRef } = useFtsContext(); 13 | const trigger = useScrollTrigger(); 14 | 15 | return ( 16 | <> 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |