summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-06-03 09:34:29 +0700
committerpolwex <polwex@sortug.com>2025-06-03 09:34:29 +0700
commit2401217a4019938d1c1cc61b6e33ccb233eb6e74 (patch)
tree06118284965be5cfd6b417dca86d46db5758217b
parent2b80f7950df34f2a160135d7e20220a9b2ec3352 (diff)
this is golden thanks claude
-rw-r--r--src/actions/prosody.ts104
-rw-r--r--src/actions/tones.ts10
-rw-r--r--src/components/Flashcard/Deck3.tsx223
-rw-r--r--src/components/Flashcard/ServerCard2.tsx359
-rw-r--r--src/components/prosody/ClientCard.tsx64
-rw-r--r--src/components/prosody/ServerCard.tsx343
-rw-r--r--src/lib/calls/nlp.ts13
-rw-r--r--src/lib/db/enseed.ts85
-rw-r--r--src/lib/db/prosodydb.ts120
-rw-r--r--src/lib/db/prosodyschema.sql1
-rw-r--r--src/lib/db/thaiseed.ts87
-rw-r--r--src/lib/types/cards.ts32
-rw-r--r--src/pages.gen.ts3
-rw-r--r--src/pages/lesson/[slug].tsx3
-rw-r--r--src/pages/study/thai.tsx53
-rw-r--r--src/pages/tones.tsx20
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.",
};