diff options
author | polwex <polwex@sortug.com> | 2025-06-03 09:34:29 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-06-03 09:34:29 +0700 |
commit | 2401217a4019938d1c1cc61b6e33ccb233eb6e74 (patch) | |
tree | 06118284965be5cfd6b417dca86d46db5758217b | |
parent | 2b80f7950df34f2a160135d7e20220a9b2ec3352 (diff) |
this is golden thanks claude
-rw-r--r-- | src/actions/prosody.ts | 104 | ||||
-rw-r--r-- | src/actions/tones.ts | 10 | ||||
-rw-r--r-- | src/components/Flashcard/Deck3.tsx | 223 | ||||
-rw-r--r-- | src/components/Flashcard/ServerCard2.tsx | 359 | ||||
-rw-r--r-- | src/components/prosody/ClientCard.tsx | 64 | ||||
-rw-r--r-- | src/components/prosody/ServerCard.tsx | 343 | ||||
-rw-r--r-- | src/lib/calls/nlp.ts | 13 | ||||
-rw-r--r-- | src/lib/db/enseed.ts | 85 | ||||
-rw-r--r-- | src/lib/db/prosodydb.ts | 120 | ||||
-rw-r--r-- | src/lib/db/prosodyschema.sql | 1 | ||||
-rw-r--r-- | src/lib/db/thaiseed.ts | 87 | ||||
-rw-r--r-- | src/lib/types/cards.ts | 32 | ||||
-rw-r--r-- | src/pages.gen.ts | 3 | ||||
-rw-r--r-- | src/pages/lesson/[slug].tsx | 3 | ||||
-rw-r--r-- | src/pages/study/thai.tsx | 53 | ||||
-rw-r--r-- | src/pages/tones.tsx | 20 |
16 files changed, 1443 insertions, 77 deletions
diff --git a/src/actions/prosody.ts b/src/actions/prosody.ts new file mode 100644 index 0000000..9ec5dd2 --- /dev/null +++ b/src/actions/prosody.ts @@ -0,0 +1,104 @@ +"use server"; + +import pdb from "@/lib/db/prosodydb"; +import { WordData } from "@/zoom/logic/types"; + +export async function getOnsets(onset: string) { + const data = pdb.fetchOnsets(onset); + return data; +} +// Helper to extract tone from prosody - assuming prosody is an array of objects like [{tone: number}, ...] +const getTonesFromProsody = (prosody: any): number[] | null => { + if (Array.isArray(prosody) && prosody.length > 0) { + return prosody.map((p) => p.tone).filter((t) => typeof t === "number"); + } + return null; +}; + +export async function fetchWordsByToneAndSyllables( + syllableCount: number, + tones: (number | null)[], // Array of tones, one for each syllable. null means any tone. +): Promise<WordData | null> { + if (syllableCount !== tones.length) { + console.error("Syllable count and tones array length mismatch"); + return null; + } + + const queryParams: (string | number)[] = ["th", syllableCount, syllableCount]; // lang, syllables (for WHERE), syllables (for json_array_length) + let toneConditions = ""; + + const toneClauses: string[] = []; + tones.forEach((tone, index) => { + if (tone !== null && typeof tone === "number") { + // Assumes SQLite's json_extract function is available and prosody is like: [{"tone": 1}, {"tone": 3}, ...] + // Path for first syllable's tone: '$[0].tone' + toneClauses.push(`json_extract(prosody, '$[${index}].tone') = ?`); + queryParams.push(tone); + } + }); + + if (toneClauses.length > 0) { + toneConditions = `AND ${toneClauses.join(" AND ")}`; + } + + const queryString = ` + SELECT id, spelling, prosody, syllables, lang, type, frequency, confidence, ipa, + (SELECT + json_group_array(json_object( + 'pos', pos, + 'senses', s.senses, + 'forms', forms, + 'etymology', etymology, + 'related', related) + ) FROM senses s WHERE s.parent_id = expressions.id + ) as senses_array + FROM expressions + WHERE lang = ? + AND syllables = ? + AND type = 'word' + AND json_valid(prosody) + AND json_array_length(prosody) = ? -- Ensures prosody array has correct number of elements + ${toneConditions} + ORDER BY RANDOM() -- Get a random word matching criteria + LIMIT 1 + `; + + try { + const query = db.db.query(queryString); + const row = query.get(...queryParams) as any; + + if (!row) return null; + + // Map to WordData (simplified, similar to initial fetch in tones.tsx or db.fetchWordBySpelling) + // This mapping might need to be more robust depending on actual WordData requirements. + const word: WordData = { + id: row.id, + spelling: row.spelling, + prosody: JSON.parse(row.prosody), + syllables: row.syllables, + lang: row.lang, + type: row.type, + frequency: row.frequency, + confidence: row.confidence, + ipa: row.ipa ? JSON.parse(row.ipa) : [], + // Senses parsing is simplified here. Adjust if full sense data is needed. + senses: row.senses_array + ? JSON.parse(row.senses_array).map((s: any) => ({ + pos: s.pos, + senses: + typeof s.senses === "string" ? JSON.parse(s.senses) : s.senses, + forms: typeof s.forms === "string" ? JSON.parse(s.forms) : s.forms, + etymology: s.etymology, + related: + typeof s.related === "string" ? JSON.parse(s.related) : s.related, + })) + : [], + }; + return word; + } catch (error) { + console.error("Error fetching word by tone and syllables:", error); + console.error("Query:", queryString); + console.error("Params:", queryParams); + return null; + } +} diff --git a/src/actions/tones.ts b/src/actions/tones.ts index 7d85d1c..0f28612 100644 --- a/src/actions/tones.ts +++ b/src/actions/tones.ts @@ -1,6 +1,6 @@ "use server"; -import db from "@/lib/db"; +import pdb from "@/lib/db/prosodydb"; import { WordData } from "@/zoom/logic/types"; // Helper to extract tone from prosody - assuming prosody is an array of objects like [{tone: number}, ...] @@ -10,8 +10,14 @@ const getTonesFromProsody = (prosody: any): number[] | null => { } return null; }; - export async function fetchWordsByToneAndSyllables( + tones: (string | null)[], // Array of tones, one for each syllable. null means any tone. +) { + const res = pdb.fetchWordsByToneAndSyls(tones); + return res; +} + +export async function fetchWordsByToneAndSyllables_gem( syllableCount: number, tones: (number | null)[], // Array of tones, one for each syllable. null means any tone. ): Promise<WordData | null> { diff --git a/src/components/Flashcard/Deck3.tsx b/src/components/Flashcard/Deck3.tsx new file mode 100644 index 0000000..4f0f711 --- /dev/null +++ b/src/components/Flashcard/Deck3.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { CardResponse, DeckResponse } from "@/lib/types/cards"; +import React, { + ReactNode, + useCallback, + useEffect, + useState, + useTransition, +} from "react"; +import { Button } from "../ui/button"; +import { ChevronLeftIcon, ChevronRightIcon, RotateCcwIcon } from "lucide-react"; +import "./cards.css"; + +type CardData = { + id: number; + front: ReactNode; + back: ReactNode; +}; +// --- Main App Component --- +function Deck({ data, cards }: { data: any; cards: CardData[] }) { + const [currentPage, setCurrentPage] = useState<number>(0); + const [currentIndex, setCurrentIndex] = useState<number>(0); + const [isFlipped, setIsFlipped] = useState<boolean>(false); + const [animationDirection, setAnimationDirection] = useState< + "enter-left" | "enter-right" | "exit-left" | "exit-right" | "none" + >("none"); + const [isAnimating, setIsAnimating] = useState<boolean>(false); + + const handleFlip = () => { + if (isAnimating) return; + setIsFlipped(!isFlipped); + }; + + const handleNext = useCallback(() => { + if (isAnimating || currentIndex >= cards.length - 1) return; + setIsAnimating(true); + setIsFlipped(false); // Flip back to front before changing card + setAnimationDirection("exit-left"); + + setTimeout(() => { + setCurrentIndex((prevIndex) => Math.min(prevIndex + 1, cards.length - 1)); + setAnimationDirection("enter-right"); + setTimeout(() => { + setAnimationDirection("none"); + setIsAnimating(false); + }, 200); // Duration of enter animation + }, 200); // Duration of exit animation + }, [currentIndex, cards.length, isAnimating]); + + const handlePrev = useCallback(() => { + if (isAnimating || currentIndex <= 0) return; + setIsAnimating(true); + setIsFlipped(false); // Flip back to front + setAnimationDirection("exit-right"); + + setTimeout(() => { + setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + setAnimationDirection("enter-left"); + setTimeout(() => { + setAnimationDirection("none"); + setIsAnimating(false); + }, 200); // Duration of enter animation + }, 200); // Duration of exit animation + }, [currentIndex, isAnimating]); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isAnimating) return; + if (event.key === "ArrowRight") { + handleNext(); + } else if (event.key === "ArrowLeft") { + handlePrev(); + } else if (event.key === " " || event.key === "Enter") { + // Space or Enter to flip + event.preventDefault(); // Prevent scrolling if space is pressed + handleFlip(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleNext, handlePrev, isAnimating]); + + const [isPending, startTransition] = useTransition(); + const shuffle = () => { + startTransition(async () => { + "use server"; + console.log("shuffling deck..."); + }); + }; + + if (cards.length === 0) { + return ( + <div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex flex-col items-center justify-center p-4 font-inter text-slate-800 dark:text-slate-200"> + <p>No flashcards available.</p> + </div> + ); + } + + const currentCard = cards[currentIndex]; + if (!currentCard) return <p>wtf</p>; + + return ( + <div className="min-h-screen bg-slate-100 dark:bg-slate-900 flex flex-col items-center justify-center p-4 font-inter transition-colors duration-300"> + <header> + <h1 className="text-2xl ">Deck: {data.lesson.name}</h1> + <p>{data.lesson.description}</p> + </header> + <div className="w-full max-w-md mb-8 relative"> + {/* This div is for positioning the card and managing overflow during animations */} + <div className="relative h-80"> + <FlashCard + key={currentCard.id} // Important for re-rendering on card change with animation + isFlipped={isFlipped} + onFlip={handleFlip} + animationDirection={animationDirection} + front={currentCard.front} + back={currentCard.back} + /> + </div> + </div> + + <div className="flex items-center justify-between w-full max-w-md mb-6"> + <Button + onClick={handlePrev} + disabled={currentIndex === 0 || isAnimating} + variant="outline" + size="icon" + aria-label="Previous card" + > + <ChevronLeftIcon /> + </Button> + <div className="text-center"> + <p className="text-sm text-slate-600 dark:text-slate-400"> + Card {currentIndex + 1} of {cards.length} + </p> + <Button + onClick={handleFlip} + variant="ghost" + size="sm" + className="mt-1 text-slate-600 dark:text-slate-400" + disabled={isAnimating} + > + <RotateCcwIcon className="w-4 h-4 mr-2" /> Flip Card + </Button> + </div> + <Button + onClick={handleNext} + disabled={currentIndex === cards.length - 1 || isAnimating} + variant="outline" + size="icon" + aria-label="Next card" + > + <ChevronRightIcon /> + </Button> + </div> + + <div className="text-xs text-slate-500 dark:text-slate-400 mt-8"> + Use Arrow Keys (← →) to navigate, Space/Enter to flip. + </div> + <Button onClick={shuffle}>Shuffle Deck</Button> + </div> + ); +} + +export default Deck; + +interface FlashcardProps { + isFlipped: boolean; + onFlip: () => void; + animationDirection: + | "enter-left" + | "enter-right" + | "exit-left" + | "exit-right" + | "none"; +} +interface ServerCards { + front: ReactNode; + back: ReactNode; +} + +function FlashCard({ + isFlipped, + onFlip, + animationDirection, + front, + back, +}: FlashcardProps & ServerCards) { + const getAnimationClass = () => { + switch (animationDirection) { + case "enter-right": + return "animate-slide-in-right"; + case "enter-left": + return "animate-slide-in-left"; + case "exit-right": + return "animate-slide-out-right"; + case "exit-left": + return "animate-slide-out-left"; + default: + return ""; + } + }; + return ( + <div + className={`w-full max-w-md h-80 perspective group ${getAnimationClass()}`} + onClick={onFlip} + > + <div + className={`relative w-full h-full rounded-xl shadow-xl transition-transform duration-700 ease-in-out transform-style-preserve-3d cursor-pointer ${ + isFlipped ? "rotate-y-180" : "" + }`} + > + {front} + {back} + </div> + </div> + ); +} diff --git a/src/components/Flashcard/ServerCard2.tsx b/src/components/Flashcard/ServerCard2.tsx new file mode 100644 index 0000000..d8a4989 --- /dev/null +++ b/src/components/Flashcard/ServerCard2.tsx @@ -0,0 +1,359 @@ +// This is a Server Component +import React, { Suspense, useTransition } from "react"; +import { NLP } from "sortug-ai"; +import { + BookOpen, + Volume2, + Link as LinkIcon, + ChevronDown, + ChevronUp, + Search, + Info, + MessageSquareQuote, + Tags, + ListTree, + Lightbulb, + BookmarkIcon, +} from "lucide-react"; +import { + Example, + SubSense, + RelatedEntry, + Sense, + WordData, +} from "@/zoom/logic/types"; +import { CardResponse, ProsodyWord } from "@/lib/types/cards"; +import { thaiData } from "@/lib/calls/nlp"; +import { getRandomHexColor } from "@/lib/utils"; +import { BookmarkIconito } from "./BookmarkButton"; +import SyllableSpan from "./SyllableSpan"; +import SyllableCard from "./Syllable"; + +export async function CardFront({ data }: { data: ProsodyWord }) { + console.log("cardfront", data); + return ( + <div className="absolute w-full h-full bg-white dark:bg-slate-800 rounded-xl backface-hidden flex flex-col justify-center gap-8 items-center p-6"> + <Suspense + fallback={ + <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.spelling} + </p> + } + > + <p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.syllables.map((syl, i) => ( + // <span + // key={syl + i} + // style={{ color: getRandomHexColor() }} + // className="m-1 hover:text-6xl" + // > + // {syl} + // </span> + <SyllableSpan + key={syl.spelling + i} + spelling={syl.spelling} + lang={data.lang} + /> + ))} + </p> + </Suspense> + <p>Word: {data.id}</p> + <IpaDisplay ipaEntries={[{ ipa: data.ipa }]} /> + </div> + ); +} + +// Helper component for IPA display +export const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( + <div className="flex items-center space-x-2 flex-wrap"> + {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + <span key={index} className="text-lg text-blue-600 font-serif"> + {entry.ipa}{" "} + {tags.length > 0 && ( + <span className="text-xs text-gray-500">({tags.join(", ")})</span> + )} + </span> + ); + })} + <button + className="p-1 text-blue-500 hover:text-blue-700 transition-colors" + title="Pronounce" + // onClick={() => { + // /* Pronunciation logic would be client-side or a server roundtrip for audio file. */ alert( + // "Pronunciation feature not implemented for server component.", + // ); + // }} + > + <Volume2 size={20} /> + </button> + </div> + ); +}; + +export async function CardBack({ data }: { data: ProsodyWord }) { + return ( + <div className="w-full h-full bg-slate-50 dark:bg-slate-700 rounded-xl backface-hidden rotate-y-180 flex flex-col justify-between items-center p-6 relative"> + <span className="text-lg text-slate-500 dark:text-slate-400 self-start"> + {data.syllables.map((syl, i) => ( + <div key={syl.spelling + i}> + <div>whole: {syl.ipa}</div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + onset: {syl.onset} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + nucleus: {syl.nucleus} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + coda: {syl.coda} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + tone: {syl.tone} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + tonename: {syl.tonen} + </div> + </div> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.notes || ""} + </p> + </div> + ); +} + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-1 flex items-center"> + <MessageSquareQuote size={14} className="mr-1 text-gray-500" /> + Examples: + </h5> + <ul className="list-disc list-inside pl-2 space-y-1"> + {examples.map((ex, idx) => ( + <li key={idx} className="text-xs text-gray-600"> + <span className="italic">"{ex.text}"</span> + {ex.ref && ( + <span className="text-gray-400 text-xs"> ({ex.ref})</span> + )} + {ex.type !== "quote" && ( + <span className="ml-1 text-xs bg-sky-100 text-sky-700 px-1 rounded-sm"> + {ex.type} + </span> + )} + </li> + ))} + </ul> + </div> + ); +}; + +// Component for displaying related terms (synonyms, antonyms, etc.) +const RelatedTermsDisplay = ({ + terms, + type, +}: { + terms: RelatedEntry[] | undefined; + type: string; +}) => { + if (!terms || terms.length === 0) return null; + return ( + <div className="mt-1"> + <span className="text-xs font-semibold text-gray-500 capitalize"> + {type}:{" "} + </span> + {terms.map((term, idx) => ( + <React.Fragment key={idx}> + <a + href={`/search?q=${encodeURIComponent(term.word)}`} + className="text-xs text-blue-500 hover:text-blue-700 hover:underline" + > + {term.word} + </a> + {/*term.source && ( + <span className="text-xs text-gray-400"> ({term.source})</span> + )*/} + {idx < terms.length - 1 && ", "} + </React.Fragment> + ))} + </div> + ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( + <div className="mb-3 pl-4 border-l-2 border-indigo-200"> + {subSense.glosses.map((gloss, glossIdx) => ( + <p key={glossIdx} className="text-gray-700 mb-1"> + <span className="font-semibold"> + {subSenseNumber}.{glossIdx + 1} + </span>{" "} + {gloss} + </p> + ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( + <p className="text-xs text-gray-500 italic mb-1"> + (Raw: {subSense.raw_glosses.join("; ")}) + </p> + )} + + {subSense.categories && subSense.categories.length > 0 && ( + <div className="mt-1 mb-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <ListTree size={14} className="mr-1 text-gray-500" /> + Categories: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.categories.map((cat, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + > + {cat} + </span> + ))} + </div> + </div> + )} + + <ExampleDisplay examples={subSense.examples || []} /> + <RelatedTermsDisplay terms={subSense.synonyms} type="Synonyms" /> + + {subSense.tags && subSense.tags.length > 0 && ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <Tags size={14} className="mr-1 text-gray-500" /> + Tags: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.tags.map((tag, idx) => ( + <span + key={idx} + className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + > + {tag} + </span> + ))} + </div> + </div> + )} + + {subSense.links && subSense.links.length > 0 && ( + <div className="mt-2"> + {subSense.links.map(([type, target], linkIdx) => ( + <a + key={linkIdx} + href={target} // Assuming target is a full URL or a path + target="_blank" + rel="noopener noreferrer" + className="text-xs text-blue-500 hover:text-blue-700 hover:underline mr-2 inline-flex items-center" + > + <LinkIcon size={12} className="mr-1" /> {type} + </a> + ))} + </div> + )} + </div> + ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( + <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> + <div className="flex justify-between items-center mb-2"> + <h3 className="text-xl font-semibold text-indigo-700"> + {senseNumber}. {NLP.unpackPos(senseData.pos)} + </h3> + </div> + + {senseData.etymology && ( + <details className="mb-3 group"> + <summary className="cursor-pointer flex items-center text-sm text-gray-600 hover:text-indigo-600 transition-colors list-none"> + Etymology + <ChevronDown size={16} className="ml-1 group-open:hidden" /> + <ChevronUp size={16} className="ml-1 hidden group-open:inline" /> + </summary> + <p className="mt-1 text-xs text-gray-500 italic bg-gray-50 p-2 rounded"> + {senseData.etymology} + </p> + </details> + )} + + {senseData.forms && senseData.forms.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-gray-700">Forms:</h4> + <div className="flex flex-wrap gap-2 mt-1"> + {senseData.forms.map((form, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full" + > + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + </span> + ))} + </div> + </div> + )} + + {senseData.senses.map((subSense, idx) => ( + <SubSenseDisplay + key={idx} + subSense={subSense} + subSenseNumber={senseNumber} + /> + ))} + + {senseData.related && ( + <div className="mt-3 pt-3 border-t border-gray-100"> + <h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center"> + <Lightbulb size={16} className="mr-1 text-gray-500" /> + Related Terms: + </h4> + <RelatedTermsDisplay + terms={senseData.related.related} + type="Related" + /> + <RelatedTermsDisplay + terms={senseData.related.synonyms} + type="Synonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.antonyms} + type="Antonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.derived} + type="Derived" + /> + </div> + )} + </div> + ); +}; diff --git a/src/components/prosody/ClientCard.tsx b/src/components/prosody/ClientCard.tsx new file mode 100644 index 0000000..795e3bf --- /dev/null +++ b/src/components/prosody/ClientCard.tsx @@ -0,0 +1,64 @@ +"use client"; +import { getOnsets } from "@/actions/prosody"; +import type { ProsodySyllable, ProsodyWord } from "@/lib/types/cards"; +import { useTransition, useState } from "react"; + +export async function SyllableBreakdown({ syl }: { syl: ProsodySyllable }) { + const [isPending, startTransition] = useTransition(); + const [data, setData] = useState<any[]>([]); + function showOnset(e: React.MouseEvent) { + e.stopPropagation(); + console.log(syl); + startTransition(async () => { + const data = await getOnsets(syl.onset); + setData(data); + }); + } + console.log({ isPending }); + return ( + <div> + <div>whole: {syl.ipa}</div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + onset: {syl.onset} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + nucleus: {syl.nucleus} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + coda: {syl.coda} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + tone: {syl.tone} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + tonename: {syl.tonen} + </div> + {data.length > 0 && ( + <div> + {data.map((d) => ( + <div key={d.id}> + <div>{d.spelling}</div> + </div> + ))} + </div> + )} + </div> + ); +} + +// Component for displaying examples diff --git a/src/components/prosody/ServerCard.tsx b/src/components/prosody/ServerCard.tsx new file mode 100644 index 0000000..4867721 --- /dev/null +++ b/src/components/prosody/ServerCard.tsx @@ -0,0 +1,343 @@ +// This is a Server Component +import React, { Suspense, useTransition } from "react"; +import { NLP } from "sortug-ai"; +import { + BookOpen, + Volume2, + Link as LinkIcon, + ChevronDown, + ChevronUp, + Search, + Info, + MessageSquareQuote, + Tags, + ListTree, + Lightbulb, + BookmarkIcon, +} from "lucide-react"; +import { + Example, + SubSense, + RelatedEntry, + Sense, + WordData, +} from "@/zoom/logic/types"; +import { CardResponse, ProsodyWord } from "@/lib/types/cards"; +import { thaiData } from "@/lib/calls/nlp"; +import { getRandomHexColor } from "@/lib/utils"; +import { BookmarkIconito } from "../Flashcard/BookmarkButton"; +import SyllableCard from "../Flashcard/Syllable"; +import SyllableSpan from "../Flashcard/SyllableSpan"; +import { SyllableBreakdown } from "./ClientCard"; + +export async function CardFront({ data }: { data: ProsodyWord }) { + console.log("cardfront", data); + return ( + <div className="absolute w-full h-full bg-white dark:bg-slate-800 rounded-xl backface-hidden flex flex-col justify-center gap-8 items-center p-6"> + <Suspense + fallback={ + <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.spelling} + </p> + } + > + <p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.syllables.map((syl, i) => ( + // <span + // key={syl + i} + // style={{ color: getRandomHexColor() }} + // className="m-1 hover:text-6xl" + // > + // {syl} + // </span> + <SyllableSpan + key={syl.spelling + i} + spelling={syl.spelling} + lang={data.lang} + /> + ))} + </p> + </Suspense> + <p>Word: {data.id}</p> + <IpaDisplay ipaEntries={[{ ipa: data.ipa }]} /> + </div> + ); +} + +// Helper component for IPA display +export const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( + <div className="flex items-center space-x-2 flex-wrap"> + {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + <span key={index} className="text-lg text-blue-600 font-serif"> + {entry.ipa}{" "} + {tags.length > 0 && ( + <span className="text-xs text-gray-500">({tags.join(", ")})</span> + )} + </span> + ); + })} + <button + className="p-1 text-blue-500 hover:text-blue-700 transition-colors" + title="Pronounce" + // onClick={() => { + // /* Pronunciation logic would be client-side or a server roundtrip for audio file. */ alert( + // "Pronunciation feature not implemented for server component.", + // ); + // }} + > + <Volume2 size={20} /> + </button> + </div> + ); +}; + +export async function CardBack({ data }: { data: ProsodyWord }) { + return ( + <div className="w-full h-full bg-slate-50 dark:bg-slate-700 rounded-xl backface-hidden rotate-y-180 flex flex-col justify-between items-center p-6 relative"> + <span className="text-lg text-slate-500 dark:text-slate-400 self-start"> + {data.syllables.map((syl, i) => ( + <SyllableBreakdown syl={syl} key={syl.spelling + i} /> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.notes || ""} + </p> + </div> + ); +} + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-1 flex items-center"> + <MessageSquareQuote size={14} className="mr-1 text-gray-500" /> + Examples: + </h5> + <ul className="list-disc list-inside pl-2 space-y-1"> + {examples.map((ex, idx) => ( + <li key={idx} className="text-xs text-gray-600"> + <span className="italic">"{ex.text}"</span> + {ex.ref && ( + <span className="text-gray-400 text-xs"> ({ex.ref})</span> + )} + {ex.type !== "quote" && ( + <span className="ml-1 text-xs bg-sky-100 text-sky-700 px-1 rounded-sm"> + {ex.type} + </span> + )} + </li> + ))} + </ul> + </div> + ); +}; + +// Component for displaying related terms (synonyms, antonyms, etc.) +const RelatedTermsDisplay = ({ + terms, + type, +}: { + terms: RelatedEntry[] | undefined; + type: string; +}) => { + if (!terms || terms.length === 0) return null; + return ( + <div className="mt-1"> + <span className="text-xs font-semibold text-gray-500 capitalize"> + {type}:{" "} + </span> + {terms.map((term, idx) => ( + <React.Fragment key={idx}> + <a + href={`/search?q=${encodeURIComponent(term.word)}`} + className="text-xs text-blue-500 hover:text-blue-700 hover:underline" + > + {term.word} + </a> + {/*term.source && ( + <span className="text-xs text-gray-400"> ({term.source})</span> + )*/} + {idx < terms.length - 1 && ", "} + </React.Fragment> + ))} + </div> + ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( + <div className="mb-3 pl-4 border-l-2 border-indigo-200"> + {subSense.glosses.map((gloss, glossIdx) => ( + <p key={glossIdx} className="text-gray-700 mb-1"> + <span className="font-semibold"> + {subSenseNumber}.{glossIdx + 1} + </span>{" "} + {gloss} + </p> + ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( + <p className="text-xs text-gray-500 italic mb-1"> + (Raw: {subSense.raw_glosses.join("; ")}) + </p> + )} + + {subSense.categories && subSense.categories.length > 0 && ( + <div className="mt-1 mb-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <ListTree size={14} className="mr-1 text-gray-500" /> + Categories: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.categories.map((cat, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + > + {cat} + </span> + ))} + </div> + </div> + )} + + <ExampleDisplay examples={subSense.examples || []} /> + <RelatedTermsDisplay terms={subSense.synonyms} type="Synonyms" /> + + {subSense.tags && subSense.tags.length > 0 && ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <Tags size={14} className="mr-1 text-gray-500" /> + Tags: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.tags.map((tag, idx) => ( + <span + key={idx} + className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + > + {tag} + </span> + ))} + </div> + </div> + )} + + {subSense.links && subSense.links.length > 0 && ( + <div className="mt-2"> + {subSense.links.map(([type, target], linkIdx) => ( + <a + key={linkIdx} + href={target} // Assuming target is a full URL or a path + target="_blank" + rel="noopener noreferrer" + className="text-xs text-blue-500 hover:text-blue-700 hover:underline mr-2 inline-flex items-center" + > + <LinkIcon size={12} className="mr-1" /> {type} + </a> + ))} + </div> + )} + </div> + ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( + <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> + <div className="flex justify-between items-center mb-2"> + <h3 className="text-xl font-semibold text-indigo-700"> + {senseNumber}. {NLP.unpackPos(senseData.pos)} + </h3> + </div> + + {senseData.etymology && ( + <details className="mb-3 group"> + <summary className="cursor-pointer flex items-center text-sm text-gray-600 hover:text-indigo-600 transition-colors list-none"> + Etymology + <ChevronDown size={16} className="ml-1 group-open:hidden" /> + <ChevronUp size={16} className="ml-1 hidden group-open:inline" /> + </summary> + <p className="mt-1 text-xs text-gray-500 italic bg-gray-50 p-2 rounded"> + {senseData.etymology} + </p> + </details> + )} + + {senseData.forms && senseData.forms.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-gray-700">Forms:</h4> + <div className="flex flex-wrap gap-2 mt-1"> + {senseData.forms.map((form, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full" + > + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + </span> + ))} + </div> + </div> + )} + + {senseData.senses.map((subSense, idx) => ( + <SubSenseDisplay + key={idx} + subSense={subSense} + subSenseNumber={senseNumber} + /> + ))} + + {senseData.related && ( + <div className="mt-3 pt-3 border-t border-gray-100"> + <h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center"> + <Lightbulb size={16} className="mr-1 text-gray-500" /> + Related Terms: + </h4> + <RelatedTermsDisplay + terms={senseData.related.related} + type="Related" + /> + <RelatedTermsDisplay + terms={senseData.related.synonyms} + type="Synonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.antonyms} + type="Antonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.derived} + type="Derived" + /> + </div> + )} + </div> + ); +}; diff --git a/src/lib/calls/nlp.ts b/src/lib/calls/nlp.ts index f3364ac..f19c976 100644 --- a/src/lib/calls/nlp.ts +++ b/src/lib/calls/nlp.ts @@ -13,19 +13,22 @@ export type SorSylRes = { word: string; ipa: string; clean_ipa: string; - syls: SorSyl[]; + syls: SorBSyl[]; +}; +export type SorBSyl = { + ipa: SorSyl; + spelling: SorSyl; }; export type SorSyl = { - stressed: boolean; - long: boolean; - spelling: string; - ipa: string; + all: string; nucleus: string; onset: string; medial: string; coda: string; rhyme: string; tone: string; + long: boolean; + stressed: boolean; start_idx: number; end_idx: number; }; diff --git a/src/lib/db/enseed.ts b/src/lib/db/enseed.ts index 58f5876..39dec44 100644 --- a/src/lib/db/enseed.ts +++ b/src/lib/db/enseed.ts @@ -7,12 +7,15 @@ import { type ThaiNLPRes, sorSyl, getThaiFreq, + SorBSyl, } from "../calls/nlp"; import pdb from "./prosodydb"; import { cleanIpa } from "../utils"; import { handleFile } from "./utils"; import { Tone } from "../types/phonetics"; +import { AsyncRes } from "../types"; +const errors: string[] = []; async function readDump(lang: string) { await pdb.init(); pdb.addLanguage("th", "thai"); @@ -27,14 +30,21 @@ async function readDump(lang: string) { count++; console.log(count); // if (count <= 10000) continue; - if (count > 30) break; + if (count > 300) break; const j = JSON.parse(langrow.data); const word = j.word.trim(); if (!word) continue; const split = word.split(" "); - if (split.length > 1) await handleIdiom(lang, word); - else await handleWord(lang, word, j, freqMap); + const res = + split.length > 1 + ? await handleIdiom(lang, word) + : await handleWord(lang, word, j, freqMap); + if ("error" in res) { + console.error(res.error); + break; + } } + console.dir(errors); } async function handleWord( @@ -42,7 +52,7 @@ async function handleWord( word: string, j: any, freqMap: Map<string, number>, -) { +): AsyncRes<string> { // TODO add categories but add a tag to see what classifying scheme we're using // const sounds = j.sounds || []; @@ -50,9 +60,9 @@ async function handleWord( const hwikiRhyme = sounds.find((s: any) => "rhymes" in s); const wikiRhyme = hwikiRhyme ? hwikiRhyme.rhymes : null; if (!hasIpa) { - console.error("no ipa!!", word); - console.dir(j, { depth: null }); - return; + // console.error("no ipa!!", word); + // console.dir(j, { depth: null }); + return { error: "meh no ipa" }; } const freq = freqMap.get(word) || null; // const wordId = pdb.addWord(word, lang, freq, null); @@ -60,7 +70,11 @@ async function handleWord( const wordId = 0; // console.log(analyzed); for (let snd of sounds) - if ("ipa" in snd) handleIpa(wordId, word, lang, j, snd, wikiRhyme); + if ("ipa" in snd) { + const res = await handleIpa(wordId, word, lang, j, snd, wikiRhyme); + if ("error" in res) return res; + } + return { ok: "" }; } async function handleIpa( wordId: number | bigint, @@ -73,58 +87,65 @@ async function handleIpa( const tags = JSON.stringify(snd.tags) || null; const ipa = snd.ipa; const syls = await sorSyl(word, lang, ipa); + // console.log(syls, "sorsyl"); console.log(word); console.log(ipa); - // pdb.addPronunciation(wordId, ipa, syls.syls.length, tags, null); + pdb.addPronunciation(wordId, ipa, syls.syls.length, tags, null); // set word rhyme - const wordRhyme = syls.syls.reduce((acc: string, item: SorSyl) => { + const wordRhyme = syls.syls.reduce((acc: string, itemm: SorBSyl) => { + const item = itemm.ipa; if (!item.stressed && !acc) return acc; if (item.stressed && !acc) return `${acc}${item.rhyme}`; - else return `${acc}${item.ipa}`; + else return `${acc}${item.all}`; }, ""); if (wordRhyme) pdb.addWordRhyme(wordId, wordRhyme, j.lang_code, wikiRhyme); - // + for (let i = 0; i < syls.syls.length; i++) { const syl = syls.syls[i]!; - await handleSyllable(word, syl.ipa, wordId, i); + const res = await handleSyllable(syl, wordId, i); + if ("error" in res) return res; } + return { ok: "" }; } async function handleSyllable( - spelling: string, - ipa: string, + syl: SorBSyl, wordId: number | bigint, idx: number, -) { - const sorsyl = await sorSyl(spelling, "th", ipa); - if (sorsyl.syls.length !== 1) throw new Error("wtf sorsyl!"); - const syl = sorsyl.syls[0]!; +): AsyncRes<string> { try { pdb.addSyllable( wordId, idx + 1, + syl.ipa.stressed, "th", - syl.ipa, - syl.long, - spelling, - { spelling: syl.onset, ipa: syl.onset }, - { spelling: syl.medial, ipa: syl.medial }, - { spelling: syl.nucleus, ipa: syl.nucleus }, - { spelling: syl.coda, ipa: syl.coda }, - { spelling: syl.rhyme, ipa: syl.rhyme }, + syl.ipa.all, + syl.ipa.long, + syl.spelling.all, + { spelling: syl.spelling.onset, ipa: syl.ipa.onset }, + { spelling: syl.spelling.medial, ipa: syl.ipa.medial }, + { spelling: syl.spelling.nucleus, ipa: syl.ipa.nucleus }, + { spelling: syl.spelling.coda, ipa: syl.ipa.coda }, + { spelling: syl.spelling.rhyme, ipa: syl.ipa.rhyme }, { letters: "", numbers: 0, name: "" }, null, ); + return { ok: "" }; } catch (e) { // console.log("well fuck", syl); // console.error(e); - console.log(); + return { error: `${e}` }; } } -async function handleIdiom(lang: string, idiom: string) { - pdb.addIdiom(idiom, lang); - // TODO later set idiom_words once all words are populated - // console.log(); +async function handleIdiom(lang: string, idiom: string): AsyncRes<string> { + try { + pdb.addIdiom(idiom, lang); + // TODO later set idiom_words once all words are populated + // console.log(); + return { ok: "" }; + } catch (e) { + return { error: `${e}` }; + } } // ช้า ๆ // งก ๆ diff --git a/src/lib/db/prosodydb.ts b/src/lib/db/prosodydb.ts index 9e76b8d..d6da389 100644 --- a/src/lib/db/prosodydb.ts +++ b/src/lib/db/prosodydb.ts @@ -1,12 +1,14 @@ import Database from "bun:sqlite"; import { Phoneme, Tone } from "../types/phonetics"; +import { ProsodyWord, ProsodyWordDB } from "../types/cards"; type Str = string | null; type ItemType = "word" | "syllable" | "idiom"; class DatabaseHandler { db: Database; constructor() { - const dbPath = "/home/y/code/bun/ssr/waku/bulkdata/phon.db"; + // const dbPath = "/home/y/code/bun/ssr/waku/bulkdata/phon.db"; + const dbPath = "/home/y/code/bun/ssr/waku/bulkdata/thaiphon.db"; const db = new Database(dbPath, { create: true }); db.exec("PRAGMA journal_mode = WAL"); // Enable Write-Ahead Logging for better performance db.exec("PRAGMA foreign_keys = ON"); @@ -18,12 +20,123 @@ class DatabaseHandler { this.db.exec(sql); } // selects + fetchFrequent(lang: string) { + const query = this.db.query( + `SELECT + w.id, + w.spelling, + w.lang, + w.frequency, + w.lang, + wp.ipa, + wp.syllables, + wp.tag, + w.notes, + (SELECT + json_group_array(json_object( + 'ipa', s.ipa, + 'spelling', s.text, + 'long', s.long, + 'notes', s.notes, + 'onseto', os.text, + 'onset', os.ipa, + 'nucleuso', ns.text, + 'nucleus', ns.ipa, + 'codao', co.text, + 'coda', co.ipa, + 'rhymeo', rh.text, + 'rhyme', rh.ipa, + 'tonen', tns.name, + 'tonenm', tns.nums, + 'tone', tns.ipa + ) + ) + FROM syllables s + JOIN onsets os ON os.id = s.onset + JOIN nucleus ns ON ns.id = s.nucleus + JOIN codas co ON co.id = s.coda + JOIN rhymes rh ON rh.id = s.rhyme + JOIN tones tns ON tns.id = s.tone + WHERE s.id= sw.syl_id + ) as syllables + FROM words w + JOIN word_phonetics wp ON wp.word_id = w.id + JOIN syllables_words sw ON sw.word_id = w.id + WHERE w.frequency IS NOT NULL + AND w.lang = ? + ORDER BY w.frequency ASC + LIMIT 300 + `, + ); + return query.all(lang) as ProsodyWordDB[]; + } fetchWords(words: string[]) { const query = this.db.query( `SELECT id FROM words where spelling IN (${words.map((w) => `'${w}'`).join(", ")})`, ); return query.all() as Array<{ id: number }>; } + fetchSyllables(words: string[]) { + const query = this.db.query( + `SELECT id FROM words where spelling IN (${words.map((w) => `'${w}'`).join(", ")})`, + ); + return query.all() as Array<{ id: number }>; + } + fetchOnsets(onset: string) { + const query = this.db.query( + `SELECT + w.id, + w.spelling, + w.frequency, + wp.ipa + FROM words w + JOIN word_phonetics wp ON wp.word_id = w.id + JOIN syllables_words sw ON sw.word_id = w.id + JOIN syllables s ON s.id = sw.syl_id + JOIN onsets os ON os.id = syl.onset + `, + ); + return query.all(onset) as any[]; + } + // tones + fetchWordsByToneAndSyls(tones: Array<string | null>) { + const toneString = tones.reduce((acc: string, item) => { + if (!item) return `${acc},%`; + else return `${acc},${item}`; + }, ""); + console.log({ toneString }); + const query = this.db.query( + ` + WITH word_tone_sequences AS ( + SELECT + w.id as word_id, + w.spelling, + wp.ipa, + w.frequency, + GROUP_CONCAT(t.name ORDER BY sw.idx) as tone_sequence, + COUNT(sw.syl_id) as syllable_count + FROM words w + JOIN word_phonetics wp ON w.id = wp.word_id + JOIN syllables_words sw ON w.id = sw.word_id + JOIN syllables s ON sw.syl_id = s.id + JOIN tones t ON s.tone = t.id + GROUP BY w.id, w.spelling, w.lang, w.frequency + ) + SELECT + word_id, + spelling, + ipa, + frequency, + tone_sequence, + syllable_count + FROM word_tone_sequences + WHERE tone_sequence LIKE ? + AND syllable_count = ? + ORDER BY frequency DESC NULLS LAST; + `, + ); + return query.all(toneString.slice(1), tones.length) as any[]; + } // inserts addLanguage(code: string, name: string) { @@ -109,6 +222,7 @@ class DatabaseHandler { addSyllable( wordId: number | bigint, sylIdx: number, + stressed: boolean | null, lang: string, ipa: string, long: boolean, @@ -197,9 +311,9 @@ class DatabaseHandler { // const res1 = this.db .query( - `INSERT INTO syllables_words(syl_id, word_id, idx) VALUES(?, ?, ?)`, + `INSERT INTO syllables_words(syl_id, word_id, idx, stressed) VALUES(?, ?, ?, ?)`, ) - .run(sylId, wordId, sylIdx); + .run(sylId, wordId, sylIdx, stressed); // return sylId; }); diff --git a/src/lib/db/prosodyschema.sql b/src/lib/db/prosodyschema.sql index c962d83..c6a04fa 100644 --- a/src/lib/db/prosodyschema.sql +++ b/src/lib/db/prosodyschema.sql @@ -130,6 +130,7 @@ CREATE TABLE IF NOT EXISTS syllables_words( syl_id INTEGER NOT NULL, word_id INTEGER NOT NULL, idx INTEGER NOT NULL, + stressed INTEGER, FOREIGN KEY (syl_id) REFERENCES syllables(id), FOREIGN KEY (word_id) REFERENCES words(id) ); diff --git a/src/lib/db/thaiseed.ts b/src/lib/db/thaiseed.ts index 5c75345..6c69d9c 100644 --- a/src/lib/db/thaiseed.ts +++ b/src/lib/db/thaiseed.ts @@ -12,6 +12,7 @@ import pdb from "./prosodydb"; import { cleanIpa } from "../utils"; import { handleFile } from "./utils"; import { Tone } from "../types/phonetics"; +import { AsyncRes } from "../types"; async function readDump(lang: string) { await pdb.init(); @@ -30,38 +31,77 @@ async function readDump(lang: string) { const j = JSON.parse(langrow.data); const word = j.word.trim(); if (!word) continue; - if (word.includes("ๆ")) await handleWord(word, j); - else { + + if (word.includes("ๆ")) { + const res = await handleWord(word, j); + if ("error" in res) { + if (res.error.includes("meh")) continue; + if (res.error.includes("wtf")) { + console.error(res.error); + console.error(j.sounds); + } + break; + } + } else { const split = word.split(" "); - if (split.length > 1) await handleIdiom(word); - else await handleWord(word, j); + if (split.length > 1) { + const res = await handleIdiom(word); + if ("error" in res) { + console.error(res.error); + break; + } + } else { + const res = await handleWord(word, j); + if ("error" in res) { + if (res.error.includes("meh")) continue; + if (res.error.includes("wtf")) { + console.error(res.error); + console.error(j.sounds); + } + // break; + } + } } } } -async function handleWord(word: string, j: any) { +async function handleWord(word: string, j: any): AsyncRes<string> { // TODO add categories but add a tag to see what classifying scheme we're using // const sounds = j.sounds || []; const hasIpa = sounds.find((s: any) => "ipa" in s); - if (!hasIpa) return; + if (!hasIpa) return { error: "meh no ipa" }; const freq = await getThaiFreq(word); const wordId = pdb.addWord(word, "th", freq, null); + if (wordId == 478 || word === "และ") { + console.log("wtf man"); + console.dir(j, { depth: null }); + return { error: "i said wtf" }; + } const analyzed = await analyzeTHWord(word); - for (let snd of sounds) if ("ipa" in snd) handleIpa(wordId, j, snd, analyzed); + for (let snd of sounds) + if ("ipa" in snd) { + const res = await handleIpa(wordId, j, snd, analyzed); + if ("error" in res) return res; + } + return { ok: "" }; } async function handleIpa( wordId: number | bigint, j: any, snd: any, analyzed: ThaiNLPRes, -) { +): AsyncRes<string> { const tags = JSON.stringify(snd.tags) || null; // console.log("handleipa", analyzed.syllables.length); // console.log(analyzed); const wikiIpa = cleanIpa(snd.ipa); const nlpIpa = cleanIpa(analyzed.ipa); const ipa = wikiIpa || nlpIpa; + if (j.word === "และ") { + console.log("wtf!!"); + return { error: "wtf is this" }; + } const wikiIpaSplit = wikiIpa.split("."); const nlpIpaSplit = nlpIpa.split("."); if (wikiIpaSplit.length !== nlpIpaSplit.length) { @@ -73,14 +113,15 @@ async function handleIpa( // console.log("syllable analysis mismatch", j.word); // console.log({ syls: analyzed.syllables, ipa: wikiIpaSplit }); // console.dir(j, { depth: null }); - return; + return { error: "meh syllable analysis mismatch" }; } - pdb.addPronunciation(wordId, ipa, analyzed.syllables.length, tags, null); const writtenSyls = analyzed.syllables; const pronouncedSyls = analyzed.realSyls; let badSyls = false; if (writtenSyls.length !== pronouncedSyls.length) badSyls = true; + pdb.addPronunciation(wordId, ipa, pronouncedSyls.length, tags, null); + for (let i = 0; i < pronouncedSyls.length; i++) { const pronounced = pronouncedSyls[i]!.replace(/\u{E3A}/u, ""); const written = writtenSyls[i] || ""; @@ -93,14 +134,10 @@ async function handleIpa( console.log(pronounced); console.log(written); } - try { - await handleSyllable(syllable, ipa, wordId, i, notes); - } catch (e) { - console.error("syl error", j.word, j.sounds); - console.error({ analyzed, ipa, wikiIpaSplit }); - console.error(e); - } + const res = await handleSyllable(syllable, ipa, wordId, i, notes); + if ("error" in res) return res; } + return { ok: "" }; } const thaiTones: Record<string, string> = { "˧": "mid", @@ -122,7 +159,7 @@ function parseTone(ipa: string, spelling: string): Tone { const numbers = thaiToneNums[ipa]!; return { letters: ipa, name, numbers }; } catch (e) { - console.error("wrong tones!!", { s: spelling, ipa }); + console.error("meh wrong tones!!", { s: spelling, ipa }); throw new Error(""); } } @@ -133,7 +170,7 @@ async function handleSyllable( wordId: number | bigint, idx: number, notes: string | null, -) { +): AsyncRes<string> { const sorsyl = await sorSyl(spelling, "th", ipa); const weird = [ // "a̯n", @@ -166,14 +203,16 @@ async function handleSyllable( // // console.dir(j, { depth: null }); // } if (sorsyl.syls.length !== 1) throw new Error("wtf sorsyl!"); - const syl = sorsyl.syls[0]!; + const syl = sorsyl.syls[0]!.ipa; const tone = parseTone(syl.tone, spelling); + // TODO add actual ortographic data here not just ipa try { pdb.addSyllable( wordId, idx + 1, + null, "th", - syl.ipa, + syl.all, syl.long, spelling, { spelling: syl.onset, ipa: syl.onset }, @@ -184,16 +223,18 @@ async function handleSyllable( tone, notes, ); + return { ok: "" }; } catch (e) { // console.log("well fuck", syl); // console.error(e); - console.log(); + return { error: `meh ${e}` }; } } -async function handleIdiom(idiom: string) { +async function handleIdiom(idiom: string): AsyncRes<string> { pdb.addIdiom(idiom, "th"); // TODO later set idiom_words once all words are populated // console.log(); + return { ok: "" }; } // ช้า ๆ // งก ๆ diff --git a/src/lib/types/cards.ts b/src/lib/types/cards.ts index 1a62a44..39e2b15 100644 --- a/src/lib/types/cards.ts +++ b/src/lib/types/cards.ts @@ -223,3 +223,35 @@ export enum SyllablePart { OTHER_OFFSET = "c", CODA = "$", } + +export type ProsodyWordDB = Omit<ProsodyWord, "syllables"> & { + syllables: string; +}; +export interface ProsodyWord { + id: number; + spelling: string; + frequency: number | null; + lang: string; + ipa: string; + tags: string; + syllables: ProsodySyllable[]; + notes: string | null; +} +// -o is spelling, -/ is ipa +export type ProsodySyllable = { + ipa: string; + spelling: string; + long: boolean; + notes: string | null; + onseto: string; + onset: string; + nucleuso: string; + nucleus: string; + codao: string; + coda: string; + rhymeo: string; + rhyme: string; + tonen: string; + tonenm: string; + tone: string; +}; diff --git a/src/pages.gen.ts b/src/pages.gen.ts index 2d0d34e..2b4890f 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -4,6 +4,8 @@ import type { PathsForPages, GetConfigResponse } from 'waku/router'; // prettier-ignore +import type { getConfig as File_StudyThai_getConfig } from './pages/study/thai'; +// prettier-ignore import type { getConfig as File_Zoom_getConfig } from './pages/zoom'; // prettier-ignore import type { getConfig as File_LangSlug_getConfig } from './pages/lang/[slug]'; @@ -34,6 +36,7 @@ import type { getConfig as File_Index_getConfig } from './pages/index'; // prettier-ignore type Page = +| ({ path: '/study/thai' } & GetConfigResponse<typeof File_StudyThai_getConfig>) | { path: '/study/[slug]'; render: 'dynamic' } | ({ path: '/zoom' } & GetConfigResponse<typeof File_Zoom_getConfig>) | ({ path: '/lang/[slug]' } & GetConfigResponse<typeof File_LangSlug_getConfig>) diff --git a/src/pages/lesson/[slug].tsx b/src/pages/lesson/[slug].tsx index 991859b..e9c7b93 100644 --- a/src/pages/lesson/[slug].tsx +++ b/src/pages/lesson/[slug].tsx @@ -36,8 +36,7 @@ export default async function HomePage(props: PageProps<"/lesson/[slug]">) { const data = await getData(Number(props.slug), user.id); if ("error" in data) return <p>Error</p>; const cardComponents = data.ok.cards.map((card) => ({ - id: card.id, - front: <CardFront data={card} needFetch={false} />, + front: <CardFront data={card} />, back: <CardBack data={card} />, })); diff --git a/src/pages/study/thai.tsx b/src/pages/study/thai.tsx new file mode 100644 index 0000000..272aecb --- /dev/null +++ b/src/pages/study/thai.tsx @@ -0,0 +1,53 @@ +import { getContextData } from "waku/middleware/context"; +import { getState } from "@/lib/db"; +import { startStudySession } from "@/actions/srs"; +import StudySession from "@/components/Flashcard/StudySession"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import LessonSelector from "@/components/srs/LessonSelector"; +import type { PageProps } from "waku/router"; +import pdb from "@/lib/db/prosodydb"; +import Deck from "@/components/Flashcard/Deck3"; +import { CardFront, CardBack } from "@/components/Flashcard/ServerCard2"; + +// This is a server component that gets the initial data +export default async function StudyPage(props: PageProps<"/study/[slug]">) { + const lessonId = props.slug; + const ctx = getContextData() as any; + const userId = ctx?.user?.id; + const data = await getData(); + console.log({ data }); + const cardComponents = data.map((card) => { + const syls = JSON.parse(card.syllables); + const syllables = syls.map((s: any) => { + const long = s.long === 1 ? true : s.long === 0 ? false : null; + return { ...s, long }; + }); + const data = { ...card, syllables }; + return { + id: card.id, + front: <CardFront data={data} />, + back: <CardBack data={data} />, + }; + }); + + return ( + <div className="container mx-auto py-8"> + <Deck + data={{ lesson: { name: "hey", description: "hoy" } }} + cards={cardComponents} + /> + </div> + ); +} + +const getData = async () => { + const res = pdb.fetchFrequent("th"); + return res; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/src/pages/tones.tsx b/src/pages/tones.tsx index 1a1e908..96ed56c 100644 --- a/src/pages/tones.tsx +++ b/src/pages/tones.tsx @@ -1,18 +1,19 @@ -import { Suspense } from 'react'; -import { fetchWordsByToneAndSyllables } from '@/actions/tones'; -import ToneSelectorClient from '@/components/tones/ToneSelectorClient'; -import { Skeleton } from '@/components/ui/skeleton'; // For Suspense fallback +import { Suspense } from "react"; +import { fetchWordsByToneAndSyllables } from "@/actions/tones"; +import ToneSelectorClient from "@/components/tones/ToneSelectorClient"; +import { Skeleton } from "@/components/ui/skeleton"; // For Suspense fallback export const getConfig = async () => { return { - render: 'static', // Or 'dynamic' if you prefer SSR for every request + render: "static", // Or 'dynamic' if you prefer SSR for every request }; }; // Function to fetch the initial word on the server async function InitialWordLoader() { // Fetch a random 1-syllable Thai word with any tone initially - const initialWord = await fetchWordsByToneAndSyllables(1, [null]); + const initialWord = await fetchWordsByToneAndSyllables(["rising", "mid"]); + console.log({ initialWord }); return <ToneSelectorClient initialWord={initialWord} />; } @@ -23,7 +24,7 @@ function TonePageSkeleton() { <div className="mb-6 p-6 border rounded-lg shadow"> <Skeleton className="h-8 w-1/2 mb-4" /> <Skeleton className="h-6 w-3/4 mb-6" /> - + <div className="space-y-6"> <div> <Skeleton className="h-6 w-1/4 mb-2" /> @@ -45,7 +46,6 @@ function TonePageSkeleton() { ); } - export default function TonesPage() { return ( <div className="py-8"> @@ -57,6 +57,6 @@ export default function TonesPage() { } export const metadata = { - title: 'Thai Tone Explorer', - description: 'Explore Thai words by syllable count and tones.', + title: "Thai Tone Explorer", + description: "Explore Thai words by syllable count and tones.", }; |