diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3390bb..491c677 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: - name: Install dependencies run: bun install + - name: Run lint + run: bun run lint + - name: Run tests run: bun run test diff --git a/package.json b/package.json index 389632b..df03e64 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dev:client": "vite", "build:client": "vite build", "preview:client": "vite preview", - "lint": "biome lint --write ./src" + "lint": "biome lint ./src", + "lint:write": "biome lint --write ./src" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/src/client/components/AnswerButtons.tsx b/src/client/components/AnswerButtons.tsx new file mode 100644 index 0000000..8b2cfa8 --- /dev/null +++ b/src/client/components/AnswerButtons.tsx @@ -0,0 +1,39 @@ +import { ArrowCounterClockwise, Warning, Check, Star } from "@phosphor-icons/react"; + +export interface AnswerButtonsProps { + onAnswer: (ease: number) => void; +} + +export default function AnswerButtons({ onAnswer }: AnswerButtonsProps) { + const answers = [ + { ease: 1, label: "Again", icon: , color: "red" }, + { ease: 2, label: "Hard", icon: , color: "yellow" }, + { ease: 3, label: "Good", icon: , color: "green" }, + { ease: 4, label: "Easy", icon: , color: "blue" }, + ]; + + return ( +
+ {answers.map(({ ease, label, icon, color }) => ( + + ))} +
+ ); +} diff --git a/src/client/components/AudioPlayer.tsx b/src/client/components/AudioPlayer.tsx new file mode 100644 index 0000000..f5cf1e1 --- /dev/null +++ b/src/client/components/AudioPlayer.tsx @@ -0,0 +1,189 @@ +import { useState, useEffect, useRef } from "react"; +import { SkipBack, SkipForward, Pause, Play } from "@phosphor-icons/react"; +import { getApiBase } from "../config"; + +export interface AudioPlayerProps { + audioUrls: string[]; + isPlaying: boolean; + setIsPlaying: (playing: boolean) => void; + currentAudioIndex: number; + setCurrentAudioIndex: (index: number) => void; +} + +export default function AudioPlayer({ + audioUrls, + isPlaying, + setIsPlaying, + currentAudioIndex, + setCurrentAudioIndex, +}: AudioPlayerProps) { + const audioRefs = useRef([]); + const progressBarRef = useRef(null); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + useEffect(() => { + const handleAudioEnd = () => { + if (currentAudioIndex < audioUrls.length - 1) { + setCurrentAudioIndex(currentAudioIndex + 1); + audioRefs.current[currentAudioIndex + 1]?.play(); + } else { + setIsPlaying(false); + setCurrentAudioIndex(0); + } + }; + + const handleTimeUpdate = (audio: HTMLAudioElement) => { + setCurrentTime(audio.currentTime); + setDuration(audio.duration); + }; + + for (const audio of audioRefs.current) { + if (audio) { + audio.addEventListener("ended", handleAudioEnd); + audio.addEventListener("timeupdate", () => handleTimeUpdate(audio)); + } + } + + return () => { + for (const audio of audioRefs.current) { + if (audio) { + audio.removeEventListener("ended", handleAudioEnd); + audio.removeEventListener("timeupdate", () => handleTimeUpdate(audio)); + } + } + }; + }, [currentAudioIndex, audioUrls.length, setCurrentAudioIndex, setIsPlaying]); + + const handleProgressBarClick = (event: React.MouseEvent) => { + const progressBar = progressBarRef.current; + if (!progressBar || !audioRefs.current[currentAudioIndex]) return; + + const rect = progressBar.getBoundingClientRect(); + const clickPosition = (event.clientX - rect.left) / rect.width; + const newTime = + clickPosition * audioRefs.current[currentAudioIndex].duration; + + audioRefs.current[currentAudioIndex].currentTime = newTime; + setCurrentTime(newTime); + }; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + const handlePlayPause = () => { + if (!audioUrls.length) return; + + if (isPlaying) { + audioRefs.current[currentAudioIndex]?.pause(); + setIsPlaying(false); + } else { + audioRefs.current[currentAudioIndex]?.play(); + setIsPlaying(true); + } + }; + + return ( +
+
+
+
+ + Track {currentAudioIndex + 1}/{audioUrls.length} + +
+
+ {formatTime(currentTime)} / {formatTime(duration)} +
+
+ + + +
+ + + +
+
+ + {audioUrls.map((url, index) => ( + + ))} +
+ ); +} diff --git a/src/client/components/CardControls.tsx b/src/client/components/CardControls.tsx new file mode 100644 index 0000000..876f910 --- /dev/null +++ b/src/client/components/CardControls.tsx @@ -0,0 +1,57 @@ +import { + ArrowLeft, + ArrowCounterClockwise, + ArrowRight, +} from "@phosphor-icons/react"; + +export interface CardControlsProps { + onPrevious: () => void; + onNext: () => void; + onRegenerate: () => void; + isFirst: boolean; + isLast: boolean; + isRegenerating: boolean; +} + +export default function CardControls({ + onPrevious, + onNext, + onRegenerate, + isFirst, + isLast, + isRegenerating, +}: CardControlsProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/client/components/DeckSelector.tsx b/src/client/components/DeckSelector.tsx new file mode 100644 index 0000000..fd77af0 --- /dev/null +++ b/src/client/components/DeckSelector.tsx @@ -0,0 +1,26 @@ +export interface DeckSelectorProps { + decks: string[]; + selectedDeck: string | undefined; + onSelect: (event: React.ChangeEvent) => void; +} + +export default function DeckSelector({ + decks, + selectedDeck, + onSelect, +}: DeckSelectorProps) { + return ( + + ); +} diff --git a/src/client/components/SettingsModal.tsx b/src/client/components/SettingsModal.tsx new file mode 100644 index 0000000..eb4504f --- /dev/null +++ b/src/client/components/SettingsModal.tsx @@ -0,0 +1,84 @@ +import { ArrowClockwise } from "@phosphor-icons/react"; +import { getDefaultApiBase } from "../config"; + +export interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; + apiBase: string; + onApiBaseChange: (value: string) => void; + onReset: () => void; + onSave: () => void; + onSync: () => void; + errorMessage?: string; +} + +export default function SettingsModal({ + isOpen, + onClose, + apiBase, + onApiBaseChange, + onReset, + onSave, + onSync, + errorMessage, +}: SettingsModalProps) { + if (!isOpen) return null; + + return ( +
+
+

Settings

+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+ API Base URL +
+ onApiBaseChange(e.target.value)} + placeholder={getDefaultApiBase()} + className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" + /> +
+ +
+ + + + +
+
+
+ ); +} diff --git a/src/client/views/DeckView.tsx b/src/client/views/DeckView.tsx index 243cc2d..5aaea3e 100644 --- a/src/client/views/DeckView.tsx +++ b/src/client/views/DeckView.tsx @@ -1,409 +1,25 @@ -import { useParams, useNavigate, useLocation } from "react-router-dom"; -import { useState, useEffect, useMemo, useRef } from "react"; import { - Play, - Pause, - SkipBack, - SkipForward, - ArrowLeft, - ArrowRight, - ArrowCounterClockwise, - ArrowClockwise, Gear, } from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import useSWR from "swr"; import { + answerCard, + fetchCard, + fetchDeckCards, fetchDeckConfig, fetchDecks, - fetchDeckCards, - fetchCard, - answerCard, regenerateCard, sync, } from "../api"; -import { getApiBase, setApiBase, getDefaultApiBase } from "../config"; - -interface AudioPlayerProps { - audioUrls: string[]; - isPlaying: boolean; - setIsPlaying: (playing: boolean) => void; - currentAudioIndex: number; - setCurrentAudioIndex: (index: number) => void; -} - -function AudioPlayer({ - audioUrls, - isPlaying, - setIsPlaying, - currentAudioIndex, - setCurrentAudioIndex, -}: AudioPlayerProps) { - const audioRefs = useRef([]); - const progressBarRef = useRef(null); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - - useEffect(() => { - const handleAudioEnd = () => { - if (currentAudioIndex < audioUrls.length - 1) { - setCurrentAudioIndex(currentAudioIndex + 1); - audioRefs.current[currentAudioIndex + 1]?.play(); - } else { - setIsPlaying(false); - setCurrentAudioIndex(0); - } - }; - - const handleTimeUpdate = (audio: HTMLAudioElement) => { - setCurrentTime(audio.currentTime); - setDuration(audio.duration); - }; - - audioRefs.current.forEach((audio) => { - if (audio) { - audio.addEventListener("ended", handleAudioEnd); - audio.addEventListener("timeupdate", () => handleTimeUpdate(audio)); - } - }); - - return () => { - audioRefs.current.forEach((audio) => { - if (audio) { - audio.removeEventListener("ended", handleAudioEnd); - audio.removeEventListener("timeupdate", () => - handleTimeUpdate(audio), - ); - } - }); - }; - }, [currentAudioIndex, audioUrls.length, setCurrentAudioIndex, setIsPlaying]); - - const handleProgressBarClick = (event: React.MouseEvent) => { - const progressBar = progressBarRef.current; - if (!progressBar || !audioRefs.current[currentAudioIndex]) return; - - const rect = progressBar.getBoundingClientRect(); - const clickPosition = (event.clientX - rect.left) / rect.width; - const newTime = - clickPosition * audioRefs.current[currentAudioIndex].duration; - - audioRefs.current[currentAudioIndex].currentTime = newTime; - setCurrentTime(newTime); - }; - - const formatTime = (time: number) => { - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - - const handlePlayPause = () => { - if (!audioUrls.length) return; - - if (isPlaying) { - audioRefs.current[currentAudioIndex]?.pause(); - setIsPlaying(false); - } else { - audioRefs.current[currentAudioIndex]?.play(); - setIsPlaying(true); - } - }; - - return ( -
-
-
-
- - Track {currentAudioIndex + 1}/{audioUrls.length} - -
-
- {formatTime(currentTime)} / {formatTime(duration)} -
-
- -
-
-
- -
- - - -
-
- - {audioUrls.map((url, index) => ( - - ))} -
- ); -} - -interface DeckSelectorProps { - decks: string[]; - selectedDeck: string | undefined; - onSelect: (event: React.ChangeEvent) => void; -} - -function DeckSelector({ decks, selectedDeck, onSelect }: DeckSelectorProps) { - return ( - - ); -} - -interface CardControlsProps { - onPrevious: () => void; - onNext: () => void; - onRegenerate: () => void; - isFirst: boolean; - isLast: boolean; - isRegenerating: boolean; -} - -function CardControls({ - onPrevious, - onNext, - onRegenerate, - isFirst, - isLast, - isRegenerating, -}: CardControlsProps) { - return ( -
- - - -
- ); -} - -interface AnswerButtonsProps { - onAnswer: (ease: number) => void; -} - -function AnswerButtons({ onAnswer }: AnswerButtonsProps) { - const answers = [ - { ease: 1, label: "Again", icon: "↻", color: "red" }, - { ease: 2, label: "Hard", icon: "⚠", color: "yellow" }, - { ease: 3, label: "Good", icon: "✓", color: "green" }, - { ease: 4, label: "Easy", icon: "⭐", color: "blue" }, - ]; - - return ( -
- {answers.map(({ ease, label, icon, color }) => ( - - ))} -
- ); -} - -interface SettingsModalProps { - isOpen: boolean; - onClose: () => void; - apiBase: string; - onApiBaseChange: (value: string) => void; - onReset: () => void; - onSave: () => void; - onSync: () => void; - errorMessage?: string; -} - -function SettingsModal({ - isOpen, - onClose, - apiBase, - onApiBaseChange, - onReset, - onSave, - onSync, - errorMessage, -}: SettingsModalProps) { - if (!isOpen) return null; - - return ( -
-
-

Settings

- {errorMessage && ( -
- {errorMessage} -
- )} - -
-
- API Base URL -
- onApiBaseChange(e.target.value)} - placeholder={getDefaultApiBase()} - className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white" - /> -
- -
- - - - -
-
-
- ); -} +import AnswerButtons from "../components/AnswerButtons"; +import AudioPlayer from "../components/AudioPlayer"; +import CardControls from "../components/CardControls"; +import DeckSelector from "../components/DeckSelector"; +import SettingsModal from "../components/SettingsModal"; +import { getApiBase, setApiBase } from "../config"; export function DeckView() { const { deckName } = useParams(); diff --git a/src/commands/init.ts b/src/commands/init.ts index 3857bb3..0cf73dc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -19,7 +19,7 @@ async function generateDeckConfig( anki: AnkiService, deckName: string, sampleSize = 3, -): Promise { +): Promise { // Get sample cards from deck const cardIds = await anki.findCards(deckName); const sampleCards = await anki.getCardsInfo(cardIds.slice(0, sampleSize)); diff --git a/src/commands/test.ts b/src/commands/test.ts index 09bfdda..0853c1d 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -72,9 +72,9 @@ export async function test(options: TestOptions = {}) { for (const [index, card] of sampleCards.entries()) { console.log(`\nTest ${index + 1}/${sampleSize}:`); console.log("Card Fields:"); - Object.entries(card.fields).forEach(([field, { value }]) => { + for (const [field, { value }] of Object.entries(card.fields)) { console.log(`${field}: ${value}`); - }); + } try { console.log("\nGenerated Content:"); @@ -82,12 +82,12 @@ export async function test(options: TestOptions = {}) { card as Card, deckConfig, ); - Object.entries(content).forEach(([field, value]) => { + for (const [field, value] of Object.entries(content)) { console.log(`\n${field}:`); console.log(value); - }); + } } catch (error) { - console.error(`\nError generating content:`, error); + console.error("\nError generating content:", error); } if (index < sampleSize - 1) { diff --git a/src/config/loader.ts b/src/config/loader.ts index e094298..1df7735 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,4 +1,4 @@ -import { parse, stringify } from "@iarna/toml"; +import { parse, stringify, type JsonMap } from "@iarna/toml"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -50,7 +50,7 @@ const ENV_VAR_MAPPINGS = { // Keys that should undergo environment variable interpolation const ALLOWED_ENV_KEYS = new Set(Object.keys(ENV_VAR_MAPPINGS)); -function processConfigValues(obj: any, path: string[] = []): any { +function processConfigValues(obj: unknown, path: string[] = []): unknown { if (typeof obj === "string") { const fullPath = path.join("."); if (ALLOWED_ENV_KEYS.has(fullPath)) { @@ -72,7 +72,7 @@ function processConfigValues(obj: any, path: string[] = []): any { } if (obj && typeof obj === "object") { - const processed: any = {}; + const processed: Record = {}; for (const [key, value] of Object.entries(obj)) { processed[key] = processConfigValues(value, [...path, key]); } @@ -82,9 +82,17 @@ function processConfigValues(obj: any, path: string[] = []): any { return obj; } -function processRawConfig(rawConfig: any): GakuonConfig { +type ProcessedConfig = Partial & { + global?: Partial & { + openai?: Partial; + cardOrder?: Partial; + }; +}; + +function processRawConfig(rawConfig: unknown): GakuonConfig { // Process environment variables first const withEnvVars = processConfigValues(rawConfig); + const processed = withEnvVars as ProcessedConfig; // Handle enum conversions for card order settings from env vars const queueOrder = process.env.GAKUON_QUEUE_ORDER; @@ -97,26 +105,30 @@ function processRawConfig(rawConfig: any): GakuonConfig { chatModel: DEFAULT_CONFIG.global.openai.chatModel, initModel: DEFAULT_CONFIG.global.openai.initModel, ttsModel: DEFAULT_CONFIG.global.openai.ttsModel, - ...(withEnvVars.global?.openai || {}), + ...(processed.global?.openai || {}), }; return { - ...withEnvVars, + ...processed, + decks: processed.decks || DEFAULT_CONFIG.decks, global: { - ...withEnvVars.global, + ankiHost: processed.global?.ankiHost || DEFAULT_CONFIG.global.ankiHost, + openaiApiKey: processed.global?.openaiApiKey || DEFAULT_CONFIG.global.openaiApiKey, + ttsVoice: processed.global?.ttsVoice || DEFAULT_CONFIG.global.ttsVoice, + defaultDeck: processed.global?.defaultDeck, openai: openaiConfig, cardOrder: { queueOrder: (queueOrder as QueueOrder) || - withEnvVars.global.cardOrder?.queueOrder || + (processed.global?.cardOrder?.queueOrder) || DEFAULT_CONFIG.global.cardOrder.queueOrder, reviewOrder: (reviewOrder as ReviewSortOrder) || - withEnvVars.global.cardOrder?.reviewOrder || + (processed.global?.cardOrder?.reviewOrder) || DEFAULT_CONFIG.global.cardOrder.reviewOrder, newCardOrder: (newCardOrder as NewCardGatherOrder) || - withEnvVars.global.cardOrder?.newCardOrder || + (processed.global?.cardOrder?.newCardOrder) || DEFAULT_CONFIG.global.cardOrder.newCardOrder, }, }, @@ -131,7 +143,7 @@ export function loadConfig(customPath?: string): GakuonConfig { const decodedConfig = Buffer.from(base64Config, "base64").toString( "utf-8", ); - const rawConfig = parse(decodedConfig) as any; + const rawConfig = parse(decodedConfig); return processRawConfig(rawConfig); } catch (error) { console.warn("Failed to parse BASE64_GAKUON_CONFIG:", error); @@ -147,7 +159,7 @@ export function loadConfig(customPath?: string): GakuonConfig { } const configFile = readFileSync(configPath, "utf-8"); - const rawConfig = parse(configFile) as any; + const rawConfig = parse(configFile); return processRawConfig(rawConfig); } @@ -159,7 +171,7 @@ export function findDeckConfig( return configs.find((config) => new RegExp(config.pattern).test(deckName)); } -function reverseProcessConfigValues(obj: any, path: string[] = []): any { +function reverseProcessConfigValues(obj: unknown, path: string[] = []): unknown { if (typeof obj === "string") { const fullPath = path.join("."); if (ALLOWED_ENV_KEYS.has(fullPath)) { @@ -180,7 +192,7 @@ function reverseProcessConfigValues(obj: any, path: string[] = []): any { } if (obj && typeof obj === "object") { - const processed: any = {}; + const processed: Record = {}; for (const [key, value] of Object.entries(obj)) { processed[key] = reverseProcessConfigValues(value, [...path, key]); } @@ -196,7 +208,7 @@ export async function saveConfig(config: GakuonConfig): Promise { try { const processedConfig = reverseProcessConfigValues(config); - const tomlContent = stringify(processedConfig); + const tomlContent = stringify(processedConfig as JsonMap); const configWithHeader = `# Gakuon Configuration File # Generated on ${new Date().toISOString()} diff --git a/src/services/server/app.ts b/src/services/server/app.ts index ee95e66..da286a6 100644 --- a/src/services/server/app.ts +++ b/src/services/server/app.ts @@ -127,7 +127,8 @@ export function createServer(deps: ServerDependencies) { const [card] = await deps.ankiService.getCardsInfo([cardId]); if (!card) { - return res.status(404).json({ error: "Card not found" }); + res.status(404).json({ error: "Card not found" }); + return; } const { metadata, content, audioFiles } = @@ -159,12 +160,14 @@ export function createServer(deps: ServerDependencies) { const { ease } = req.body as AnswerRequest; if (ease < 1 || ease > 4) { - return res.status(400).json({ error: "Invalid ease value" }); + res.status(400).json({ error: "Invalid ease value" }); + return; } const success = await deps.ankiService.answerCard(cardId, ease); if (!success) { - return res.status(404).json({ error: "Card not found" }); + res.status(404).json({ error: "Card not found" }); + return; } res.json({ success: true }); @@ -179,13 +182,15 @@ export function createServer(deps: ServerDependencies) { const [card] = await deps.ankiService.getCardsInfo([cardId]); if (!card) { - return res.status(404).json({ error: "Card not found" }); + res.status(404).json({ error: "Card not found" }); + return; } // Find deck config for the card const config = findDeckConfig(card.deckName, deps.config.decks); if (!config) { - return res.status(404).json({ error: "Deck configuration not found" }); + res.status(404).json({ error: "Deck configuration not found" }); + return; } // Generate new content @@ -208,12 +213,14 @@ export function createServer(deps: ServerDependencies) { req.params.filename, ); if (!base64Audio) { - return res.status(404).json({ error: "Audio file not found" }); + res.status(404).json({ error: "Audio file not found" }); + return; } tempFilePath = await saveBase64ToTemp(base64Audio); res.sendFile(tempFilePath, (err) => { if (err) { - return res.status(500).end(); + res.status(500).end(); + return; } if (tempFilePath) { unlink(tempFilePath).catch(console.error); @@ -231,7 +238,8 @@ export function createServer(deps: ServerDependencies) { const config = findDeckConfig(deckName, deps.config.decks); if (!config) { - return res.status(404).json({ error: "Deck configuration not found" }); + res.status(404).json({ error: "Deck configuration not found" }); + return; } res.json({ config }); diff --git a/src/utils/asyncHandler.ts b/src/utils/asyncHandler.ts index a253377..866c312 100644 --- a/src/utils/asyncHandler.ts +++ b/src/utils/asyncHandler.ts @@ -1,7 +1,7 @@ import type { Request, Response, NextFunction, RequestHandler } from "express"; export function asyncHandler( - fn: (req: Request, res: Response, next: NextFunction) => any, + fn: (req: Request, res: Response, next: NextFunction) => Promise, ): RequestHandler { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); diff --git a/src/utils/path.ts b/src/utils/path.ts index 466648d..7728fd7 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,4 +1,4 @@ -import { homedir } from "os"; +import { homedir } from "node:os"; export function expandTildePath(path: string): string { if (path.startsWith("~/") || path === "~") {