diff --git a/src/header/components/ClearCacheDialog.jsx b/src/header/components/ClearCacheDialog.jsx index 1e5dc854..afd04aad 100644 --- a/src/header/components/ClearCacheDialog.jsx +++ b/src/header/components/ClearCacheDialog.jsx @@ -41,7 +41,6 @@ const defaultState = { }; export function ClearCacheDialog({isOpen, onClose}) { - const [state, setState] = useState(defaultState); useEffect(() => { @@ -66,46 +65,48 @@ export function ClearCacheDialog({isOpen, onClose}) { const {anki, bunPro, wanikani} = state; const error = [anki, bunPro, wanikani].filter(v => v).length === 0; - return - Force Refresh - - - Which app should be refreshed? - - - - - } - label="Anki" - /> - - } - label="BunPro" - /> - - } - label="Wanikani" - /> - - - - - - - - - - ; + return ( + + Force Refresh + + + Which app should be refreshed? + + + + + } + label="Anki" + /> + + } + label="BunPro" + /> + + } + label="Wanikani" + /> + + + + + + + + + + + ); } \ No newline at end of file diff --git a/src/header/components/HeaderOptionMenu.jsx b/src/header/components/HeaderOptionMenu.jsx index a47bc27e..b3a4341f 100644 --- a/src/header/components/HeaderOptionMenu.jsx +++ b/src/header/components/HeaderOptionMenu.jsx @@ -9,8 +9,9 @@ import {useNavigate} from "react-router"; import {useBunProApiKey} from "../../hooks/useBunProApiKey.jsx"; import {useAppVersion} from "../../hooks/useAppVersion.jsx"; import {AppUrls} from "../../Constants.js"; -import {Replay} from "@mui/icons-material"; +import {AccountCircle, Replay} from "@mui/icons-material"; import {ClearCacheDialog} from "./ClearCacheDialog.jsx"; +import UserPreferencesDialog from "./UserPreferencesDialog.jsx"; const iconPaddingRight = '7px'; @@ -23,6 +24,10 @@ const styles = { color: '#5babf2', marginRight: iconPaddingRight }, + preferencesOption: { + color: '#b0eaff', + marginRight: iconPaddingRight + }, versionText: { fontSize: 'small', color: 'gray', @@ -41,7 +46,8 @@ const styles = { function HeaderOptionMenu() { const [anchorEl, setAnchorEl] = useState(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isClearCacheDialogOpen, setIsClearCacheDialogOpen] = useState(false); + const [isPreferencesDialogOpen, setIsPreferencesDialogOpen] = useState(false); const open = Boolean(anchorEl); const wanikaniApiKeyStore = useWanikaniApiKey(); const bunProApiKeyStore = useBunProApiKey(); @@ -59,7 +65,12 @@ function HeaderOptionMenu() { } const handleForceRefresh = () => { - setIsDialogOpen(true); + setIsClearCacheDialogOpen(true); + setAnchorEl(null); + } + + const handleUserPreferences = () => { + setIsPreferencesDialogOpen(true); setAnchorEl(null); } @@ -98,6 +109,11 @@ function HeaderOptionMenu() { Force Refresh + + + Preferences + + About @@ -117,9 +133,14 @@ function HeaderOptionMenu() { - setIsDialogOpen(false)} + setIsClearCacheDialogOpen(false)} /> + + setIsPreferencesDialogOpen(false)} + /> + ); } diff --git a/src/header/components/UserPreferencesDialog.jsx b/src/header/components/UserPreferencesDialog.jsx new file mode 100644 index 00000000..f78c2793 --- /dev/null +++ b/src/header/components/UserPreferencesDialog.jsx @@ -0,0 +1,93 @@ +import { + Button, Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tab, + Tabs, Tooltip, Typography +} from "@mui/material"; +import React, {useState} from "react"; +import {useUserPreferences} from "../../hooks/useUserPreferences.jsx"; +import {HelpOutline} from "@mui/icons-material"; + +function WanikaniPreferences() { + const {wanikaniPreferences: preferences, updateWanikaniPreferences} = useUserPreferences(); + return ( + <> + + Dashboard + + + +
+ updateWanikaniPreferences({showPreviousLevelByDefault: !preferences.showPreviousLevelByDefault})} + /> + Show Previous Level by Default + + If you have not completed all Radicals, Kanji, and Vocabulary on the previous level, + automatically select the 'Show Previous Level' checkbox. + + )} + > + + +
+ + + + ); +} + +function UserPreferencesDialog({isOpen, onClose}) { + const [tab, setTab] = useState(0); + return ( + + Preferences + + + setTab(i)} + style={{marginBottom: '15px'}} + > + + + + + +
+ + {/* ANKI */} + {tab === 0 ? ( + <> + No Anki Preferences are available yet + + ) : null} + + {/* BUNPRO */} + {tab === 1 ? ( + <> + No BunPro Preferences are available yet + + ) : null} + + {/* WANIKANI */} + {tab === 2 ? ( + + ) : null} + +
+ +
+ + + +
+ ); +} + +export default UserPreferencesDialog; \ No newline at end of file diff --git a/src/hooks/useUserPreferences.jsx b/src/hooks/useUserPreferences.jsx new file mode 100644 index 00000000..f39634a9 --- /dev/null +++ b/src/hooks/useUserPreferences.jsx @@ -0,0 +1,29 @@ +import create from 'zustand' +import {persist} from "zustand/middleware"; + +const globalDefaultPreferences = {}; +const ankiDefaultPreferences = {}; +const bunProDefaultPreferences = {}; +const wanikaniDefaultPreferences = { + showPreviousLevelByDefault: true, +}; + +export const useUserPreferences = create(persist( + (set) => ({ + + globalPreferences: globalDefaultPreferences, + updateGlobalPreferences: (preferences) => set(() => ({globalPreferences: {...preferences}})), + + ankiPreferences: ankiDefaultPreferences, + updateAnkiPreferences: (preferences) => set(() => ({ankiPreferences: {...preferences}})), + + bunProPreferences: bunProDefaultPreferences, + updateBunProPreferences: (preferences) => set(() => ({bunProPreferences: {...preferences}})), + + wanikaniPreferences: wanikaniDefaultPreferences, + updateWanikaniPreferences: (preferences) => set(() => ({wanikaniPreferences: {...preferences}})), + + }), + { + name: 'user-preferences' + })); \ No newline at end of file diff --git a/src/util/InFlightRequestManager.js b/src/util/InFlightRequestManager.js new file mode 100644 index 00000000..afb4d9c7 --- /dev/null +++ b/src/util/InFlightRequestManager.js @@ -0,0 +1,22 @@ +function InFlightRequestManager() { + let requests = {}; + + async function send(key, request) { + if (requests[key]) { + return await requests[key]; + } + try { + let _request = request(); + requests[key] = _request; + return await _request; + } finally { + delete request[key]; + } + } + + return { + send + }; +} + +export default InFlightRequestManager; \ No newline at end of file diff --git a/src/wanikani/components/WanikaniActiveItemChart.jsx b/src/wanikani/components/WanikaniActiveItemChart.jsx index 17bd346d..94225ae5 100644 --- a/src/wanikani/components/WanikaniActiveItemChart.jsx +++ b/src/wanikani/components/WanikaniActiveItemChart.jsx @@ -5,6 +5,7 @@ import WanikaniItemTile from "./WanikaniItemTile.jsx"; import {combineAssignmentAndSubject, isSubjectHidden} from "../service/WanikaniDataUtil.js"; import {getColorByWanikaniSubjectType} from "../service/WanikaniStyleUtil.js"; import {WanikaniColors} from "../../Constants.js"; +import {useUserPreferences} from "../../hooks/useUserPreferences.jsx"; const defaultState = { radicals: [], @@ -124,22 +125,30 @@ function SubjectTile({subject}) { } function WanikaniLevelItemsChart({level}) { + const {wanikaniPreferences} = useUserPreferences(); const isFirstLoad = useRef(true); const [isPreviousLevel, setIsPreviousLevel] = useState(true); const [data, setData] = useState(defaultState); useEffect(() => { let isSubscribed = true; + const cleanUp = () => isSubscribed = false; let _isFirstLoad = isFirstLoad.current isFirstLoad.current = false; + if (_isFirstLoad && !wanikaniPreferences.showPreviousLevelByDefault) { + setIsPreviousLevel(false); + return cleanUp; + } + fetchData(level > 1 && isPreviousLevel ? level - 1 : level) .then(d => { if (!isSubscribed) return; - if (_isFirstLoad && + if (wanikaniPreferences.showPreviousLevelByDefault && + _isFirstLoad && d.radicalsStarted === d.radicals.length && d.kanjiStarted === d.kanji.length && d.vocabularyStarted === d.vocabulary.length) { @@ -151,7 +160,7 @@ function WanikaniLevelItemsChart({level}) { }) .catch(console.error); - return () => isSubscribed = false; + return cleanUp; }, [level, isPreviousLevel]); return ( diff --git a/src/wanikani/service/WanikaniApiService.js b/src/wanikani/service/WanikaniApiService.js index 5c86d90d..119f7dd4 100644 --- a/src/wanikani/service/WanikaniApiService.js +++ b/src/wanikani/service/WanikaniApiService.js @@ -1,17 +1,21 @@ import * as localForage from "localforage/dist/localforage" import InMemoryCache from "../../util/InMemoryCache.js"; import {AppUrls} from "../../Constants.js"; +import InFlightRequestManager from "../../util/InFlightRequestManager.js"; const memoryCache = new InMemoryCache(); +const inFlightRequests = new InFlightRequestManager(); const wanikaniApiUrl = AppUrls.wanikaniApi; const cacheKeys = { apiKey: 'wanikani-api-key', reviews: 'wanikani-reviews', + user: 'wanikani-user', subjects: 'wanikani-subjects', assignments: 'wanikani-assignments', summary: 'wanikani-summary', levelProgression: 'wanikani-level-progressions', + assignmentsForLevelPrefix: 'wanikani-assignment-for-level-' } const authHeader = (apiKey) => ({'Authorization': `Bearer ${apiKey}`}) @@ -21,16 +25,19 @@ function sleep(ms) { } async function fetchWithAutoRetry(input, init) { - let response = await fetch(input, init); - - // Retry logic if rate limit is hit - let attempts = 0; - while (response.status == 429 && attempts < 10) { - await sleep(10_000); - response = await fetch(input, init); - attempts += 1; - } - return response; + return await inFlightRequests + .send(input, async function () { + let response = await fetch(input, init); + + // Retry logic if rate limit is hit + let attempts = 0; + while (response.status == 429 && attempts < 10) { + await sleep(10_000); + response = await fetch(input, init); + attempts += 1; + } + return response; + }) } async function fetchWanikaniApi(path, apiKey, headers) { @@ -51,18 +58,6 @@ async function fetchWanikaniApi(path, apiKey, headers) { return fetchWithAutoRetry(`${wanikaniApiUrl}${path}`, options); } -async function getFromMemoryCacheOrFetch(path, _apiKey) { - if (memoryCache.includes(path)) { - return memoryCache.get(path); - } - const key = !!_apiKey ? _apiKey : apiKey(); - const response = await fetchWanikaniApi(path, key); - const data = await response.json(); - - memoryCache.put(path, data); - return data; -} - function apiKey() { return localStorage.getItem(cacheKeys.apiKey) } @@ -75,19 +70,31 @@ function saveApiKey(key) { } } -async function fetchMultiPageRequest(path, startingId) { - const headers = { - headers: {...authHeader(apiKey())}, +async function fetchMultiPageRequest(path, startingId, lastUpdatedTs) { + let options = { + headers: { + ...authHeader(apiKey()), + }, }; + if (!!lastUpdatedTs) { + options.headers = { + ...options.headers, + ...ifModifiedSinceHeader(lastUpdatedTs) + }; + } + const startingPageParam = !!startingId ? `?page_after_id=${startingId}` : ''; - const firstPageResponse = await fetchWithAutoRetry(`${wanikaniApiUrl}${path}${startingPageParam}`, headers); + const firstPageResponse = await fetchWithAutoRetry(`${wanikaniApiUrl}${path}${startingPageParam}`, options); + if (firstPageResponse.status === 304) { + return []; + } const firstPage = await firstPageResponse.json(); let data = firstPage.data; let nextPage = firstPage.pages['next_url'] while (!!nextPage) { - let pageResponse = await fetchWithAutoRetry(nextPage, headers); + let pageResponse = await fetchWithAutoRetry(nextPage, options); let page = await pageResponse.json(); data = data.concat(page.data); nextPage = page.pages['next_url']; @@ -134,23 +141,62 @@ async function flushCache() { for (const key of Object.keys(cacheKeys)) { await localForage.removeItem(cacheKeys[key]); } + + for (let i = 0; i < 60; i++) { + await localForage.removeItem(cacheKeys.assignmentsForLevelPrefix + (i + 1)); + } } -async function getSummary() { - const cachedValue = await localForage.getItem(cacheKeys.summary); - if (!!cachedValue && cachedValue.lastUpdated > Date.now() - (1000 * 60 * 5)) { +function ifModifiedSinceHeader(date) { + if (!date) + return null; + return { + 'If-Modified-Since': new Date(date).toUTCString() + }; +} + +async function unwrapResponse(response, fallbackValue) { + if (response.status === 304) { + return fallbackValue; + } else { + return await response.json(); + } +} + +async function fetchWithCache(path, cacheKey, ttl, _apiKey) { + const cachedValue = await localForage.getItem(cacheKey); + if (!!cachedValue && cachedValue.lastUpdated > Date.now() - ttl) { return cachedValue.data; } - const response = await fetchWanikaniApi('/v2/summary', apiKey()); - const summary = await response.json(); + const key = !!_apiKey ? _apiKey : apiKey(); + const response = await fetchWanikaniApi(path, key, + ifModifiedSinceHeader(cachedValue?.lastUpdated)); + + const data = await unwrapResponse(response, cachedValue?.data); - localForage.setItem(cacheKeys.summary, { - data: summary, + localForage.setItem(cacheKey, { + data: data, lastUpdated: new Date().getTime(), }); - return summary; + return data; +} + +async function getUser(apiKey) { + return await fetchWithCache('/v2/user', cacheKeys.user, 1000, apiKey) +} + +async function getSummary() { + return await fetchWithCache('/v2/summary', cacheKeys.summary, 1000 * 60) +} + +async function getAssignmentsForLevel(level) { + return await fetchWithCache(`/v2/assignments?levels=${level}`, cacheKeys.assignmentsForLevelPrefix + level, 1000 * 60) +} + +async function getLevelProgress() { + return await fetchWithCache('/v2/level_progressions', cacheKeys.levelProgression, 1000 * 60) } export default { @@ -160,29 +206,14 @@ export default { login: async (apiKey) => { - const user = await getFromMemoryCacheOrFetch('/v2/user', apiKey); + const user = await getUser(apiKey); saveApiKey(apiKey); return user; }, - getUser: async () => getFromMemoryCacheOrFetch('/v2/user'), + getUser: getUser, getSummary: getSummary, - getLevelProgress: async () => { - const cachedValue = await localForage.getItem(cacheKeys.levelProgression); - if (!!cachedValue && cachedValue.lastUpdated > Date.now() - (1000 * 60 * 60)) { - return cachedValue.data; - } - - const response = await fetchWanikaniApi('/v2/level_progressions', apiKey()); - const data = await response.json(); - - localForage.setItem(cacheKeys.levelProgression, { - data: data, - lastUpdated: new Date().getTime(), - }); - - return data; - }, - getAssignmentsForLevel: (level) => getFromMemoryCacheOrFetch('/v2/assignments?levels=' + level), + getLevelProgress: getLevelProgress, + getAssignmentsForLevel: getAssignmentsForLevel, getReviewStatistics: () => getFromMemoryCacheOrFetchMultiPageRequest('/v2/review_statistics'), getAllAssignments: getAllAssignments, getSubjects: async () => { @@ -219,7 +250,7 @@ export default { if (!!cachedValue) { reviews = cachedValue.data; const lastId = reviews[reviews.length - 1].id; - const newData = await fetchMultiPageRequest('/v2/reviews', lastId); + const newData = await fetchMultiPageRequest('/v2/reviews', lastId, cachedValue.lastUpdated); reviews.push(...newData); } else { reviews = await fetchMultiPageRequest('/v2/reviews');