;
reviewCurrentCard: (correct: boolean) => void;
}) {
- const { groupId, phoneticsPreference } = useUserStateContext();
+ const {
+ config: { phoneticsPreference },
+ } = useUserStateContext();
+
const [cardFlipped, setCardFlipped] = useState(false);
const [startSide, setStartSide] = useState<"cherokee" | "english">(
"cherokee"
);
const [side, setSide] = useState(startSide);
+
const phonetics = useMemo(
() => getPhonetics(card.card, phoneticsPreference),
[card, phoneticsPreference]
);
function flipCard() {
- console.log("Flipping card...");
setSide(side === "cherokee" ? "english" : "cherokee");
setCardFlipped(true);
}
@@ -106,16 +120,31 @@ export function Flashcard({
}
}
- useKeyPressEvent(" ", () => {
+ function shouldIgnoreKeyboardEvent(event: KeyboardEvent) {
+ // this makes sure we bail if the keyboard event was targeted outside of the exercise (eg. at the issue modal)
+ return (
+ event.target instanceof HTMLElement &&
+ event.target !== document.body &&
+ !document.getElementById("root")!.contains(event.target)
+ );
+ }
+
+ useKeyPressEvent(" ", (event) => {
+ if (shouldIgnoreKeyboardEvent(event)) return;
+ event.preventDefault(); // sometimes button will try to get clicked too
+ event.stopPropagation();
flipCard();
});
- useKeyPressEvent("x", () => {
+ useKeyPressEvent("x", (event) => {
+ if (shouldIgnoreKeyboardEvent(event)) return;
reviewCardOrFlip(false);
});
useKeyPressEvent("Enter", (event) => {
+ if (shouldIgnoreKeyboardEvent(event)) return;
event.preventDefault(); // sometimes the button will try to get clicked too
+ event.stopPropagation();
reviewCardOrFlip(true);
});
@@ -137,7 +166,7 @@ export function Flashcard({
];
}, [card]);
- const { play } = useAudio({
+ const { play, playing } = useAudio({
src: side === "cherokee" ? cherokeeAudio : englishAudio,
autoplay: true,
});
@@ -145,7 +174,7 @@ export function Flashcard({
const selectId = useId();
return (
-
+
flipCard()}>
- {side === "cherokee" ? card.card.syllabary : card.card.english}
- {phonetics && side === "cherokee" && {phonetics}
}
+ {side === "english" ? (
+ {card.card.english}
+ ) : (
+
+ )}
-
- createIssueForAudioInNewTab(groupId, card.term)}>
- Flag an issue with this audio
-
-
+
+
+
+ );
+}
+
+function AlignedTextRow({
+ words,
+ setHoveredIdx,
+ hoveredIdx: [hoveredWordIdx, hoveredSegmentIdx],
+}: {
+ words: string[][];
+ hoveredIdx: [number | null, number | null];
+ setHoveredIdx: (idx: [number | null, number | null]) => void;
+}) {
+ return (
+
+ {words.map((word, wordIdx) => (
+ <>
+ {wordIdx === 0 ? "" : " "}
+ {word.map((segment, segmentIdx) => (
+ setHoveredIdx([wordIdx, segmentIdx])}
+ onMouseOut={() => setHoveredIdx([null, null])}
+ >
+ {hoveredWordIdx === wordIdx &&
+ hoveredSegmentIdx === segmentIdx ? (
+ {segment}
+ ) : (
+ segment
+ )}
+
+ ))}
+ >
+ ))}
+
+ );
+}
+
+function AlignedCherokee({
+ syllabary,
+ phonetics,
+}: {
+ syllabary: string;
+ phonetics: string | undefined;
+}): ReactElement {
+ const [alignedSyllabaryWords, alignedPhoneticWords] = useMemo(
+ () =>
+ phonetics
+ ? alignSyllabaryAndPhonetics(syllabary, phonetics)
+ : [syllabary.split(" ").map((word) => word.split("")), []],
+ [syllabary, phonetics]
+ );
+ const [hoveredIdx, setHoveredIdx] = useState<[number | null, number | null]>([
+ null,
+ null,
+ ]);
+ return (
+
+
+ {phonetics && (
+ <>
+
+
+ >
+ )}
+
);
}
function FlashcardControls({
reviewCard,
playAudio,
+ playing,
}: {
reviewCard: (correct: boolean) => void;
playAudio: () => void;
+ playing: boolean;
}): ReactElement {
return (
-
-
reviewCard(false)}>Answered incorrectly
-
playAudio()}>Listen again
-
reviewCard(true)}>Answered correctly
+
+
+ reviewCard(false)}
+ color={theme.colors.DARK_RED}
+ >
+ Answered incorrectly
+
+
+
+
+
+
+ reviewCard(true)}
+ color={theme.colors.DARK_GREEN}
+ >
+ Answered correctly
+
+
);
}
diff --git a/src/components/exercises/utils.ts b/src/components/exercises/utils.ts
new file mode 100644
index 00000000..1f6a5cdb
--- /dev/null
+++ b/src/components/exercises/utils.ts
@@ -0,0 +1,129 @@
+import { TermCardWithStats } from "../../spaced-repetition/types";
+
+// @ts-ignore
+import trigramSimilarity from "trigram-similarity";
+import { Card } from "../../data/cards";
+
+export function pickRandomElement
(options: T[]) {
+ return options[Math.floor(Math.random() * options.length)];
+}
+
+export function pickNRandom(options: T[], n: number): T[] {
+ const randomNumbers = new Array(n)
+ .fill(0)
+ .map((_, idx) => Math.floor(Math.random() * (options.length - idx)));
+
+ const [picked] = randomNumbers.reduce<[T[], number[]]>(
+ ([pickedOptions, pickedIdc], nextRandomNumber) => {
+ const nextIdx = pickedIdc.reduce(
+ (adjustedIdx, alreadyPickedIdx) =>
+ // bump up the index for each element we've removed if we are past it
+ // eg. [3] has been picked from [0 1 2 _3_ 4 5] and we have 3 has nextRandom number
+ // we bump up to 4, as if 3 weren't there
+ adjustedIdx + Number(adjustedIdx >= alreadyPickedIdx),
+ nextRandomNumber
+ );
+ return [
+ [...pickedOptions, options[nextIdx]],
+ [...pickedIdc, nextIdx].sort(),
+ ];
+ },
+ [[], []]
+ );
+
+ return picked;
+}
+
+export function spliceInAtRandomIndex(
+ list: T[],
+ newElement: T
+): [number, T[]] {
+ const listCopy = list.slice(0);
+ const insertionIdx = Math.floor(Math.random() * (list.length + 1));
+ listCopy.splice(insertionIdx, 0, newElement);
+ return [insertionIdx, listCopy];
+}
+
+export function getSimilarTerms(
+ correctCard: TermCardWithStats,
+ lessonCards: Record,
+ numOptions: number
+): Card[] {
+ const similarTerms = Object.keys(lessonCards)
+ .slice(0)
+ .sort(
+ (a, b) =>
+ trigramSimilarity(b, correctCard.card.cherokee) -
+ trigramSimilarity(a, correctCard.card.cherokee)
+ )
+ .slice(1, 1 + Math.ceil((numOptions - 1) * 1.5));
+ const temptingTerms = pickNRandom(similarTerms, numOptions - 1);
+ const temptingCards = temptingTerms.map((t) => lessonCards[t]);
+
+ return temptingCards;
+}
+
+export function wordsInTerm(card: Card): string[][] {
+ return [card.syllabary.split(" "), card.cherokee.split(" ")];
+}
+
+export function wordPairs(card: Card){
+ const words = wordsInTerm(card);
+ return words[0].map((syll, idx) => [syll, words[1][idx]]);
+}
+
+function arrayEqual(a: string[], b: string[]) {
+ if (a.length !== b.length) { return false; }
+ for (var i = 0; i < a.length; ++i) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function contains(array: string[][], item: string[]) {
+ for (var i = 0; i < array.length; ++i) {
+ if (arrayEqual(array[i], item)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function normalize(array: string[][]) {
+ var result = [];
+ for (var i = 0; i < array.length; ++i) {
+ if (!contains(result, array[i])) {
+ result.push(array[i]);
+ }
+ }
+ return result;
+}
+
+
+export function wordsInLesson(lessonCards: Record): string[][]{
+ const nestedWordPairs = Object.values(lessonCards).map((card, idx) => wordPairs(card));
+ const flattened = nestedWordPairs.flat();
+ //const s = new Set(flattened);
+
+ return normalize(flattened);
+
+}
+
+export function getSimilarWords(
+ correctWord: string[],
+ possibleWords: string[][],
+ numOptions: number
+): string[][] {
+ const similarTerms = possibleWords
+ .sort(
+ (a, b) =>
+ trigramSimilarity(b[1], correctWord[1]) -
+ trigramSimilarity(a[1], correctWord[1])
+ )
+ .slice(1, 1 + Math.ceil((numOptions - 1) * 1.5));
+ const temptingTerms = pickNRandom(similarTerms, numOptions - 1);
+
+ return temptingTerms;
+}
diff --git a/src/data/cards.ts b/src/data/cards.ts
index 7aed6ba9..ed3a0ac0 100644
--- a/src/data/cards.ts
+++ b/src/data/cards.ts
@@ -1,5 +1,38 @@
import cllCards from "./collections/cll1-cards.json";
import sswCards from "./collections/ssw-cards.json";
+import jwLivingPhrases from "./collections/jw-living-phrases-cards.json";
+
+export enum PhoneticOrthography {
+ MCO = "MCO",
+ WEBSTER = "WEBSTER",
+}
+
+const DEFAULT_ORTHOGRAPHY = PhoneticOrthography.MCO;
+
+function phoneticOrthographyOrThrow(
+ orthography: string | undefined
+): PhoneticOrthography {
+ if (orthography === undefined) return DEFAULT_ORTHOGRAPHY;
+ if (orthography in PhoneticOrthography)
+ return orthography as PhoneticOrthography;
+ else
+ throw new Error(
+ `Invalid string for phonetic orthography (got: ${JSON.stringify(
+ orthography
+ )})`
+ );
+}
+
+interface DiskCard {
+ cherokee: string;
+ syllabary: string;
+ alternate_pronunciations: string[];
+ alternate_syllabary: string[];
+ english: string;
+ cherokee_audio: string[];
+ english_audio: string[];
+ phoneticOrthography?: string;
+}
export interface Card {
cherokee: string;
@@ -9,6 +42,7 @@ export interface Card {
english: string;
cherokee_audio: string[];
english_audio: string[];
+ phoneticOrthography: PhoneticOrthography;
}
export function prefixAudio(path: string) {
@@ -42,13 +76,25 @@ function prefixAudioForCards(card: Card): Card {
};
}
+function cleanCard({ phoneticOrthography, ...card }: DiskCard): Card {
+ return prefixAudioForCards({
+ ...card,
+ phoneticOrthography: phoneticOrthographyOrThrow(phoneticOrthography),
+ });
+}
+
export const cards: Card[] = mergeSets(
- cllCards.map(prefixAudioForCards),
- sswCards.map(prefixAudioForCards)
+ cllCards.map(cleanCard),
+ sswCards.map(cleanCard),
+ jwLivingPhrases.map(cleanCard)
);
export function cherokeeToKey(cherokee: string) {
- return cherokee.trim().toLowerCase().replaceAll(/[.?,]/g, "");
+ return cherokee
+ .trim()
+ .toLowerCase()
+ .replaceAll(/[.?,]/g, "")
+ .normalize("NFD");
}
export function keyForCard(card: Card): string {
diff --git a/src/data/checkDataConsistency.test.ts b/src/data/checkDataConsistency.test.ts
index 7f27abe4..949cc680 100644
--- a/src/data/checkDataConsistency.test.ts
+++ b/src/data/checkDataConsistency.test.ts
@@ -2,27 +2,52 @@ import assert from "assert";
import { statSync } from "fs";
import { cleanCollection, collections } from "./vocabSets";
import { cards, cherokeeToKey, keyForCard } from "./cards";
-import { applyMigration } from "./migrations";
-import { migration } from "./migrations/2022-08-25";
-import oldCLL1 from "./backup/cll1.json";
+import { migrateTerm } from "./migrations";
+import { migrations } from "./migrations/all";
-test("migration references real terms", () => {
- const unknownTerms = Object.values(migration).filter(
- (newTerm) =>
- cards.find((card) => keyForCard(card) === cherokeeToKey(newTerm)) ===
- undefined
+test("no unmatched terms after migrating", () => {
+ const termsAffectedByMigrations = migrations.reduce(
+ (terms, migration) => [...terms, ...Object.keys(migration)],
+ []
+ );
+ const unmatchedTermsAfterMigration = termsAffectedByMigrations.filter(
+ (term) => {
+ const newTerm = migrateTerm(term);
+ return (
+ // a term is unmatched
+ // if the term is not dropped
+ // but but there is no corresponding card
+ newTerm !== null &&
+ cards.find((card) => keyForCard(card) === cherokeeToKey(newTerm)) ===
+ undefined
+ );
+ }
+ );
+
+ assert.deepStrictEqual(
+ unmatchedTermsAfterMigration.map((t) => [t, migrateTerm(t)]),
+ [],
+ "There should be no terms that are unmatched after being migrated."
);
- assert.deepStrictEqual(unknownTerms, []);
});
test("after migration, all terms have cards", () => {
+ // TODO: this test doesn't seem to make sense -- why are we migrating the
+ // terms loaded in the app? shouldn't we only migrate terms that are in the
+ // users stored term data?
+
// terms in the cached set
// mapped through the migration if it affects them
// should all now exist
- const missingTermsAfterMigration = oldCLL1.sets.flatMap((s) =>
- s.terms
- .map((t) => cherokeeToKey(applyMigration(t, migration)))
- .filter((key) => cards.find((c) => keyForCard(c) === key) === undefined)
+ const missingTermsAfterMigration = Object.values(collections).flatMap(
+ (collection) =>
+ collection.sets.flatMap((s) =>
+ s.terms
+ .map((t) => migrateTerm(t))
+ .filter(
+ (key) => cards.find((c) => keyForCard(c) === key) === undefined
+ )
+ )
);
assert.deepStrictEqual(missingTermsAfterMigration, []);
});
diff --git a/src/data/collections/cll1-cards.json b/src/data/collections/cll1-cards.json
index 9792d1eb..dd942e01 100644
--- a/src/data/collections/cll1-cards.json
+++ b/src/data/collections/cll1-cards.json
@@ -3133,7 +3133,7 @@
{
"alternate_pronunciations": [],
"alternate_syllabary": [],
- "cherokee": "sóhněla:du",
+ "cherokee": "sóhně:la:du",
"cherokee_audio": [
"source/chr/sohneladu_en-333-f_a1.30_b0413f8c8b972a78dc3efc6e8da3728fc349270c.mp3",
"source/chr/sohneladu_en-345-m_a1.30_b0413f8c8b972a78dc3efc6e8da3728fc349270c.mp3",
diff --git a/src/data/collections/cll1-credits.json b/src/data/collections/cll1-credits.json
new file mode 100644
index 00000000..bac7ce0c
--- /dev/null
+++ b/src/data/collections/cll1-credits.json
@@ -0,0 +1,23 @@
+{
+ "credits": [
+ { "role": "Author", "name": "Michael Conrad" },
+ { "role": "Speaker", "name": "Text-to-speech" }
+ ],
+ "description": "This collection contains terms with text-to-speech audio generated from Michael Conrad's Cherokee Language Lessons 1 textbook (third edition).",
+ "externalResources": [
+ {
+ "name": "Cherokee Language Lessons website",
+ "href": "https://www.cherokeelessons.com/",
+ "notes": "An online collection of resources Michael Conrad has collected or produced."
+ },
+ {
+ "name": "Textbook download (second edition)",
+ "href": "https://www.cherokeelessons.com/books/%EA%AE%B3%EA%AE%83%EA%AD%B9-%EA%AD%B6%EA%AE%BC%EA%AE%92%EA%AD%BF%EA%AE%9D%EA%AE%A7-%EA%AE%A7%EA%AE%A5%EA%AE%86%EA%AE%96%EA%AE%9D%EA%AE%A7-1/"
+ },
+ {
+ "name": "Prerecorded audio exercises (third edition)",
+ "href": "https://www.cherokeelessons.com/lesson-audio/cherokee-language-lessons-1---audio-exercises/",
+ "notes": "Good for car rides and other scenarios when you do not have a computer available."
+ }
+ ]
+}
diff --git a/src/data/collections/cll1.json b/src/data/collections/cll1.json
index 93e36d58..96b539a7 100644
--- a/src/data/collections/cll1.json
+++ b/src/data/collections/cll1.json
@@ -190,7 +190,7 @@
"da:la:du",
"gahlgwǎ:du",
"ně:la:du",
- "sóhněla:du",
+ "sóhně:la:du",
"táʔlsgǒ:hi",
"joʔsgǒ:hi",
"nvksgǒ:hi",
diff --git a/src/data/collections/jw-living-phrases-cards.json b/src/data/collections/jw-living-phrases-cards.json
new file mode 100644
index 00000000..e5a1275d
--- /dev/null
+++ b/src/data/collections/jw-living-phrases-cards.json
@@ -0,0 +1,1892 @@
+[
+ {
+ "cherokee": "ga²²da²²ha⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_1880_2791.mp3"
+ ],
+ "syllabary": "ᎦᏓᎭ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "dirty",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/dirty_Kendra_33797be57bc3b248fc5bfafd60af55a61787ce85.mp3",
+ "data/jw-living-phrases/card_audio/dirty_Joey_33797be57bc3b248fc5bfafd60af55a61787ce85.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²si²²nv,di² ka²nv³³su²²lv⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_5710_7520.mp3"
+ ],
+ "syllabary": "ᎠᏍᏏᏅᏗ ᎧᏅᏍᏑᎸ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "bedroom",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/bedroom_Kendra_009e058c883c683e1330228851ecafe3265c8f51.mp3",
+ "data/jw-living-phrases/card_audio/bedroom_Joey_009e058c883c683e1330228851ecafe3265c8f51.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²ni²³hli²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_9660_10482.mp3"
+ ],
+ "syllabary": "ᎦᏂᎵ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "bed",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/bed_Kendra_fefe2393a7fc8eafc6f15a3953f6102dfebf6d1a.mp3",
+ "data/jw-living-phrases/card_audio/bed_Joey_fefe2393a7fc8eafc6f15a3953f6102dfebf6d1a.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "o⁴⁴sda² ha,nv¹¹ga² ga²ni²³hli²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_13250_15940.mp3"
+ ],
+ "syllabary": "ᎣᏍᏓ ᎭᏅᎦ ᎦᏂᎵ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Make your bed",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/make_your_bed_Kendra_13f5f19eeadc2884350d3a9ef8f8c0b628761b1a.mp3",
+ "data/jw-living-phrases/card_audio/make_your_bed_Joey_13f5f19eeadc2884350d3a9ef8f8c0b628761b1a.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ti²ne³³dli²²yv¹¹na² di²²kv³³hi²da²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_19557_22226.mp3"
+ ],
+ "syllabary": "ᏘᏁᏟᏴᎾ ᏗᎬᎯᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Change your sheets",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/change_your_sheets_Kendra_8ec89e7074c82ca396e389aea078bc9f5dec6981.mp3",
+ "data/jw-living-phrases/card_audio/change_your_sheets_Joey_8ec89e7074c82ca396e389aea078bc9f5dec6981.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsi³ne³²dli²²yv²²na² di²²kv³³hi²da² ko²²hi² sa²na³³le⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_26564_30630.mp3"
+ ],
+ "syllabary": "ᏕᏥᏁᏟᏴᎾ ᏗᎬᎯᏓ ᎪᎯ ᏍᏌᎾᎴ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I changed my sheets this morning",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_changed_my_sheets_this_morning_Kendra_5517bbac2ea3b6ddeacf729ea5804f6bf076191f.mp3",
+ "data/jw-living-phrases/card_audio/i_changed_my_sheets_this_morning_Joey_5517bbac2ea3b6ddeacf729ea5804f6bf076191f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²kwi,sdo³",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_32970_33860.mp3"
+ ],
+ "syllabary": "ᎠᏈᏍᏙ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "pillow",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/pillow_Kendra_81cebb9e8bd9c850804b994b991040dba12deefb.mp3",
+ "data/jw-living-phrases/card_audio/pillow_Joey_81cebb9e8bd9c850804b994b991040dba12deefb.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hwi²tlv²³na²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_36570_37270.mp3"
+ ],
+ "syllabary": "ᏫᎯᏢᎾ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Go to sleep",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/go_to_sleep_Kendra_820ce1c286b325641373a0409c73eace7de60ddf.mp3",
+ "data/jw-living-phrases/card_audio/go_to_sleep_Joey_820ce1c286b325641373a0409c73eace7de60ddf.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²tlv²³na²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_41740_42548.mp3"
+ ],
+ "syllabary": "ᎯᏢᎾ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Go to sleep (while in bed)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/go_to_sleep_while_in_bed_Kendra_5748627d6aea2bd218315735b2f3ebe88f091ae9.mp3",
+ "data/jw-living-phrases/card_audio/go_to_sleep_while_in_bed_Joey_5748627d6aea2bd218315735b2f3ebe88f091ae9.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "o²²si²²tsu³ hi²tlv²²na²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_45800_47367.mp3"
+ ],
+ "syllabary": "ᎣᏍᏏᏧ ᎯᏢᎾ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "How did you sleep",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/how_did_you_sleep_Kendra_25146b403535045e64da5f9c0733417127a326de.mp3",
+ "data/jw-living-phrases/card_audio/how_did_you_sleep_Joey_25146b403535045e64da5f9c0733417127a326de.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²kv³³hi²da²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_49760_50740.mp3"
+ ],
+ "syllabary": "ᏗᎬᎯᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "sheets",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/sheets_Kendra_76de1d190d548ca63deabcba0dca91e1736758da.mp3",
+ "data/jw-living-phrases/card_audio/sheets_Joey_76de1d190d548ca63deabcba0dca91e1736758da.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²ne²²gv²²ha⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_57400_58600.mp3"
+ ],
+ "syllabary": "ᎤᏁᎬᎭ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "blanket",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/blanket_Kendra_607919897b0768090bbf4096cd5c62f2086bdb9b.mp3",
+ "data/jw-living-phrases/card_audio/blanket_Joey_607919897b0768090bbf4096cd5c62f2086bdb9b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²ne²²gv²²ha⁴ hi²²yv³hgwi²do²²si¹¹ya²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_61630_64730.mp3"
+ ],
+ "syllabary": "ᎤᏁᎬᎭ ᎯᏴᏈᏙᏍᏏᏯ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Fold the blanket",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/fold_the_blanket_Kendra_2445817a987d881664f4c9201dc8cc6c94432382.mp3",
+ "data/jw-living-phrases/card_audio/fold_the_blanket_Joey_2445817a987d881664f4c9201dc8cc6c94432382.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "so³ʔi² u²²ne²²gv²²ha⁴ di²²sgi²nv²ʔsi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_68050_70870.mp3"
+ ],
+ "syllabary": "ᏍᏐᎢ ᎤᏁᎬᎭ ᏗᏍᎩᏅᏍᏏ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Get me another blanket",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/get_me_another_blanket_Kendra_3460d774a8375326cf31e3f28fd2ec45a15a1a15.mp3",
+ "data/jw-living-phrases/card_audio/get_me_another_blanket_Joey_3460d774a8375326cf31e3f28fd2ec45a15a1a15.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sgo²³hi, yu³³tli²²lo³³da, tsi²ʔlv²²na, u²sv² tsi²ge²²sv² ",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_74860_80000.mp3"
+ ],
+ "syllabary": "ᏍᎪᎯ ᏳᏟᎶᏓ ᏥᎸᎾ ᎤᏍᏒ ᏥᎨᏍᏒ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I slept for ten hours last night",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_slept_for_ten_hours_last_night_Kendra_bc692dff394af23ba201a9ef7806d98d5c41a74c.mp3",
+ "data/jw-living-phrases/card_audio/i_slept_for_ten_hours_last_night_Joey_bc692dff394af23ba201a9ef7806d98d5c41a74c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la² i²³go³³hi⁴⁴da, hi²hlv²²na, u²sv² tsi²ge²²sv²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_83020_86470.mp3"
+ ],
+ "syllabary": "ᎯᎳ ᎢᎪᎯᏓ ᎯᏢᎾ ᎤᏍᏒ ᏥᎨᏍᏒ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "How long did you sleep last night?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/how_long_did_you_sleep_last_nigh_Kendra_b519b07a114713e2a9a313347aafa5760d3e8a6b.mp3",
+ "data/jw-living-phrases/card_audio/how_long_did_you_sleep_last_nigh_Joey_b519b07a114713e2a9a313347aafa5760d3e8a6b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la² i²³go³³hi⁴⁴da, hi²hlv²²na²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_90530_93060.mp3"
+ ],
+ "syllabary": "ᎯᎳ ᎢᎪᎯᏓ ᎯᏢᎾ ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "How long did you sleep? (after waking)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/how_long_did_you_sleep_after_wak_Kendra_a9db27d3154936262a44b2da3ed6c91cc1f580a4.mp3",
+ "data/jw-living-phrases/card_audio/how_long_did_you_sleep_after_wak_Joey_a9db27d3154936262a44b2da3ed6c91cc1f580a4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la³²yv⁴ ha²di²²da²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_95480_97300.mp3"
+ ],
+ "syllabary": "ᎯᎳᏴ ᎭᏗᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "When did you get up?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/when_did_you_get_up_Kendra_f0cb53295ed92f4ea952cca5092e6e6eb7c0b871.mp3",
+ "data/jw-living-phrases/card_audio/when_did_you_get_up_Joey_f0cb53295ed92f4ea952cca5092e6e6eb7c0b871.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la³²yv⁴ tsa²di²²dv²²he³ sv²²hi² tsi²ge²²sv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_100216_104560.mp3"
+ ],
+ "syllabary": "ᎯᎳᏴ ᏣᏗᏛᎮ ᏍᏒᎯ ᏥᎨᏍᏒᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "When did you get up yesterday?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/when_did_you_get_up_yesterday_Kendra_24a0046b9e85aec4136651779e0d99f35f96980c.mp3",
+ "data/jw-living-phrases/card_audio/when_did_you_get_up_yesterday_Joey_24a0046b9e85aec4136651779e0d99f35f96980c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la³²yv⁴ wi²tsa²²na,si²ne³³ʔi² u²sv² tsi²ge²²sv²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_108350_113320.mp3"
+ ],
+ "syllabary": "ᎯᎳᏴ ᏫᏣᎾᏍᏏᏁᎢ ᎤᏍᏒ ᏥᎨᏍᏒ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "When did you go to bed last night?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/when_did_you_go_to_bed_last_nigh_Kendra_3ba57a395ff92233b511c45dcfca55ec01827df8.mp3",
+ "data/jw-living-phrases/card_audio/when_did_you_go_to_bed_last_nigh_Joey_3ba57a395ff92233b511c45dcfca55ec01827df8.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sgo²³hi, tsa²²hli²²ʔi³li²²sv² tsi²wa²²gwa²nv,si³nv²³ʔi² u²sv² tsi²ge²²sv²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_117130_123470.mp3"
+ ],
+ "syllabary": "ᏍᎪᎯ ᏣᎵᎢᎵᏍᏒ ᏥᏩᏆᏅᏍᏏᏅᎢ ᎤᏍᏒ ᏥᎨᏍᏒ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I went to bed at ten last night",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_went_to_bed_at_ten_last_night_Kendra_a77aa5c2f24b7c4308dfea67884618a5eba8e57c.mp3",
+ "data/jw-living-phrases/card_audio/i_went_to_bed_at_ten_last_night_Joey_a77aa5c2f24b7c4308dfea67884618a5eba8e57c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²sgi² tsi²ge²²sv² tsa²²hli²²ʔi³li²²sv² tsa²²gi²ye³³tsv² ko²²hi, sa²na³³le⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_126280_132120.mp3"
+ ],
+ "syllabary": "ᎯᏍᎩ ᏥᎨᏍᏒ ᏣᏟᎢᎵᏍᏒ ᏣᎩᏰᏨ ᎪᎯ ᏍᏌᎾᎴᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I woke up at five this morning",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_woke_up_at_five_this_morning_Kendra_0eaa75089e4887c6049b8f7ad5cb70eacd17f0a4.mp3",
+ "data/jw-living-phrases/card_audio/i_woke_up_at_five_this_morning_Joey_0eaa75089e4887c6049b8f7ad5cb70eacd17f0a4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tsi²wu²²nv,si²nv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_134610_135940.mp3"
+ ],
+ "syllabary": "ᏥᏭᏅᏍᏏᏅᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she got in bed (not sleeping yet)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_got_in_bed_not_sleepin_Kendra_73180e5a69c61642fed18b31d253f2b896516d5f.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_got_in_bed_not_sleepin_Joey_73180e5a69c61642fed18b31d253f2b896516d5f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsa³ya²we³³ga²s",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_138050_139523.mp3"
+ ],
+ "syllabary": "ᏕᏣᏰᏪᎦᏍ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Are you tired?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/are_you_tired_Kendra_78d7ee4811263ec9dd67b836882bf56898468943.mp3",
+ "data/jw-living-phrases/card_audio/are_you_tired_Joey_78d7ee4811263ec9dd67b836882bf56898468943.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²hlv²²sga²s",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_142640_143720.mp3"
+ ],
+ "syllabary": "ᎯᎸᏍᎦᏍ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Are you sleepy?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/are_you_sleepy_Kendra_205682fdf52e72827dae51f7cae9f4b94a26780b.mp3",
+ "data/jw-living-phrases/card_audio/are_you_sleepy_Joey_205682fdf52e72827dae51f7cae9f4b94a26780b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do²²yu³ da²²gi²²ya²we³³ga,",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_146550_148030.mp3"
+ ],
+ "syllabary": "ᏙᏳ ᏓᎩᏯᏪᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m very tired",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_very_tired_Kendra_4ca383083d08f4e8dcfae382a317c0831711ab72.mp3",
+ "data/jw-living-phrases/card_audio/im_very_tired_Joey_4ca383083d08f4e8dcfae382a317c0831711ab72.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hv²htla²da² a²tsv²³ya,sdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_150750_153280.mp3"
+ ],
+ "syllabary": "ᎲᏝᏓ ᎠᏨᏯᏍᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Turn off the light̀",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/turn_off_the_light_Kendra_70ea8f84c5e2f5a85b677d8a66d3c26bc8b8131d.mp3",
+ "data/jw-living-phrases/card_audio/turn_off_the_light_Joey_70ea8f84c5e2f5a85b677d8a66d3c26bc8b8131d.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²tsv²²ya,sdv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_157980_159140.mp3"
+ ],
+ "syllabary": "ᎯᏨᏯᏍᏛᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Turn on the light",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/turn_on_the_light_Kendra_11a66785e3755e8d4b29a1d5f78d4723dcf2f649.mp3",
+ "data/jw-living-phrases/card_audio/turn_on_the_light_Joey_11a66785e3755e8d4b29a1d5f78d4723dcf2f649.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²ya,to³³la,dv²³di² ti²²sdu²ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_162200_164760.mp3"
+ ],
+ "syllabary": "ᏗᏯᏙᎳᏛᏗ ᏘᏍᏚᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Open the curtains",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/open_the_curtains_Kendra_905b0e9ec438589d996802696119a853ac8028a3.mp3",
+ "data/jw-living-phrases/card_audio/open_the_curtains_Joey_905b0e9ec438589d996802696119a853ac8028a3.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²ya,to³³la,dv²³di² de²²hi³sdu²²hv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_167630_171130.mp3"
+ ],
+ "syllabary": "ᏗᏯᏙᎳᏛᏗ ᏕᎯᏍᏚᎲᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Close the curtains",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/close_the_curtains_Kendra_d922198565bb78ac9c7b522cf37bb0d3557c351c.mp3",
+ "data/jw-living-phrases/card_audio/close_the_curtains_Joey_d922198565bb78ac9c7b522cf37bb0d3557c351c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "go²²hwe²³lo³²di² ga²²sgi²lo³",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_173610_175910.mp3"
+ ],
+ "syllabary": "ᎪᏪᎶᏗ ᎦᏍᎩᎶ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "desk",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/desk_Kendra_46e7cfa080913f4dae18d802d041de7d664b10e2.mp3",
+ "data/jw-living-phrases/card_audio/desk_Joey_46e7cfa080913f4dae18d802d041de7d664b10e2.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "go²²hwe²³lo³²di² ga²²sgi²lo³ wu²²wo²³tla²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_179370_182010.mp3"
+ ],
+ "syllabary": "ᎪᏪᎶᏗ ᎦᏍᎩᎶ ᏭᏬᏝ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is sitting at the desk",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_sitting_at_the_desk_Kendra_d0108f373e255da61c31cd982d4053f454a20e40.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_sitting_at_the_desk_Joey_d0108f373e255da61c31cd982d4053f454a20e40.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²da²²hye²²sdi²³sgi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_184540_186167.mp3"
+ ],
+ "syllabary": "ᎠᏓᏰᏍᏗᏍᎩ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "alarm clock",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/alarm_clock_Kendra_9a9db2428cb3ded601788835fbf12341c93548fd.mp3",
+ "data/jw-living-phrases/card_audio/alarm_clock_Joey_9a9db2428cb3ded601788835fbf12341c93548fd.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "wa²³tsi² a²da²²hye²²sdi²³sgi, u¹¹no²²hyv³hga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_189050_194170.mp3"
+ ],
+ "syllabary": "ᏩᏥ ᎠᏓᏰᏍᏗᏍᎩ ᎤᏃᏴᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "The alarm is going off",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/the_alarm_is_going_off_Kendra_2ff5ee60a4c3e4d7afe0db23b843f05864b7a0bd.mp3",
+ "data/jw-living-phrases/card_audio/the_alarm_is_going_off_Joey_2ff5ee60a4c3e4d7afe0db23b843f05864b7a0bd.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "wa²³tsi² a²da²²hye²²sdi²³sgi, a¹¹gwa²tse²²li³ su²³da³li, ge²²sv² u¹¹no²²hyv³hgo³³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_198160_205040.mp3"
+ ],
+ "syllabary": "ᏩᏥ ᎠᏓᏰᏍᏗᏍᎩ ᎠᏆᏤᎵ ᏍᏑᏓᎵ ᎨᏍᏒ ᎤᏃᏴᎪᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "My alarm goes off at six",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/my_alarm_goes_off_at_six_Kendra_c045932db3f56dcca6c425b11d5985f3653d2e38.mp3",
+ "data/jw-living-phrases/card_audio/my_alarm_goes_off_at_six_Joey_c045932db3f56dcca6c425b11d5985f3653d2e38.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga³³go, yu²²wa²³tsi² a²da²²hye²²sdi²³sgi, u¹¹no²²hyv³hga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_208250_212930.mp3"
+ ],
+ "syllabary": "ᎦᎪ ᏳᏩᏥ ᎠᏓᏰᏍᏗᏍᎩ ᎤᏃᏴᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Whose alarm is going off?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/whose_alarm_is_going_off_Kendra_70f74d93eb230affdd5ed44161e7ee67656ba5ec.mp3",
+ "data/jw-living-phrases/card_audio/whose_alarm_is_going_off_Joey_70f74d93eb230affdd5ed44161e7ee67656ba5ec.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²hi³na,do³hgv⁴ ti²nv²²ga²la²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_215200_217160.mp3"
+ ],
+ "syllabary": "ᏕᎯᎾᏙᎬ ᏘᏅᎦᎳ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Brush your teeth",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/brush_your_teeth_Kendra_596328006dc7227b5332019bcd920bcdadcaa9ec.mp3",
+ "data/jw-living-phrases/card_audio/brush_your_teeth_Joey_596328006dc7227b5332019bcd920bcdadcaa9ec.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsi³na²²do³hgv⁴ da²²gi²nv²²ga²lv²²hv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_221840_225250.mp3"
+ ],
+ "syllabary": "ᏕᏥᎾᏙᎬ ᏓᎩᏅᎦᎸᎲᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I brushed my teeth (in the past)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_brushed_my_teeth_in_the_past_Kendra_346457cfe2a42766b2456efe2b9c49cebcabf855.mp3",
+ "data/jw-living-phrases/card_audio/i_brushed_my_teeth_in_the_past_Joey_346457cfe2a42766b2456efe2b9c49cebcabf855.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsi³na²²do³hgv⁴ de²²tsi³nv²²ga²li²²sgo³³ʔi² ni²go²³lv⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_228640_233080.mp3"
+ ],
+ "syllabary": "ᏕᏥᎾᏙᎬ ᏕᏥᏅᎦᎵᏍᎪᎢ ᏂᎪᎸ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I always brush my teeth",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_always_brush_my_teeth_Kendra_56d2263c8241c18732116f906afb680ffa352b17.mp3",
+ "data/jw-living-phrases/card_audio/i_always_brush_my_teeth_Joey_56d2263c8241c18732116f906afb680ffa352b17.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsi³³na²²do³hgv⁴ da²²tsi²nv²²ga²lv²²li, no²³gwu, tsi³gi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_236880_241950.mp3"
+ ],
+ "syllabary": "ᏕᏥᎾᏙᎬ ᏓᏥᏅᎦᎸᎵ ᏃᏊ ᏥᎩ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m going to brush my teeth (now)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_going_to_brush_my_teeth_now_Kendra_c06d3f2fec46544ef2fbbc1574a004a90d37b9a3.mp3",
+ "data/jw-living-phrases/card_audio/im_going_to_brush_my_teeth_now_Joey_c06d3f2fec46544ef2fbbc1574a004a90d37b9a3.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²tsi³na²²do³hgv⁴ de²²tsi³nv²²ga²lv²²ʔe³³ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_245840_249580.mp3"
+ ],
+ "syllabary": "ᏕᏥᎾᏙᎬ ᏕᏥᏅᎦᎸᎡᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m leaving to go brush my teeth",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_leaving_to_go_brush_my_teeth_Kendra_672a555275c5565e9a6d3f957687d29ea18d2f08.mp3",
+ "data/jw-living-phrases/card_audio/im_leaving_to_go_brush_my_teeth_Joey_672a555275c5565e9a6d3f957687d29ea18d2f08.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²hi³nv²²ga²le³³s de²²hi³na,do³hgv⁴ ko²²hi, sa²na³³le⁴",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_252930_257170.mp3"
+ ],
+ "syllabary": "ᏕᎯᏅᎦᎴᏍ ᏕᎯᎾᏙᎬ ᎪᎯ ᏍᏌᎾᎴ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Did you brush your teeth this morning?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/did_you_brush_your_teeth_this_mo_Kendra_4862a79a1f859efd9c82fc825b89dd197bba056b.mp3",
+ "data/jw-living-phrases/card_audio/did_you_brush_your_teeth_this_mo_Joey_4862a79a1f859efd9c82fc825b89dd197bba056b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²hi³na,do³hgv⁴ de²²hi³nv²²ga²lv²²hv² ha²li,sda¹¹yv,hno³nv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_260970_266160.mp3"
+ ],
+ "syllabary": "ᏕᎯᎾᏙᎬ ᏕᎯᏅᎦᎸᎲ ᎭᎵᏍᏓᏴᏃᏅᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Brush your teeth later after you eat",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/brush_your_teeth_later_after_you_Kendra_b93cc818adb6cb9fd3f8a0efd227fc6b22c29226.mp3",
+ "data/jw-living-phrases/card_audio/brush_your_teeth_later_after_you_Joey_b93cc818adb6cb9fd3f8a0efd227fc6b22c29226.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ki²la²² ga²da²wo³³ʔo³hna²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_269070_271350.mp3"
+ ],
+ "syllabary": "ᎩᎳ ᎦᏓᏬᎣᎿ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I just took a bath",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_just_took_a_bath_Kendra_34c9971c1255b71352797aa9a2b7c60f4500916e.mp3",
+ "data/jw-living-phrases/card_audio/i_just_took_a_bath_Joey_34c9971c1255b71352797aa9a2b7c60f4500916e.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a¹¹gwa²da²wo²²sdi² nu⁴⁴sdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_274010_276030.mp3"
+ ],
+ "syllabary": "ᎠᏆᏓᏬᏍᏗ ᏄᏍᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I need to take a bath",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_need_to_take_a_bath_Kendra_347aeb829fc71ea69d3125a159311684da2014b1.mp3",
+ "data/jw-living-phrases/card_audio/i_need_to_take_a_bath_Joey_347aeb829fc71ea69d3125a159311684da2014b1.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²da²wo²²sdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_278060_279210.mp3"
+ ],
+ "syllabary": "ᎠᏓᏬᏍᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "bathtub",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/bathtub_Kendra_37d00e850428ca26bd5ff7b11c1cc00d3def873f.mp3",
+ "data/jw-living-phrases/card_audio/bathtub_Joey_37d00e850428ca26bd5ff7b11c1cc00d3def873f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tsu²²ni²²ga,sdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_280920_282100.mp3"
+ ],
+ "syllabary": "ᏧᏂᎦᏍᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "toilet",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/toilet_Kendra_ea092410a2a14707b99ccbd29cda6d705e2fd3f2.mp3",
+ "data/jw-living-phrases/card_audio/toilet_Joey_ea092410a2a14707b99ccbd29cda6d705e2fd3f2.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²su²³hlo,do²hdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_284210_285620.mp3"
+ ],
+ "syllabary": "ᏗᏍᏑᎶᏙᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "toilet paper",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/toilet_paper_Kendra_01bfdd2b9a210059e93213cd25a84d9a56940ed0.mp3",
+ "data/jw-living-phrases/card_audio/toilet_paper_Joey_01bfdd2b9a210059e93213cd25a84d9a56940ed0.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²da²²ke³hdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_287270_288440.mp3"
+ ],
+ "syllabary": "ᎠᏓᎨᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "mirror",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/mirror_Kendra_ffff80d25a2651a57130b409d7bf0e751e29b578.mp3",
+ "data/jw-living-phrases/card_audio/mirror_Joey_ffff80d25a2651a57130b409d7bf0e751e29b578.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²da²²ke³hdi² a²²yo³³gi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_291390_293460.mp3"
+ ],
+ "syllabary": "ᎠᏓᎨᏗ ᎠᏲᎩ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "The mirrow just broke",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/the_mirrow_just_broke_Kendra_a27783fe6baa471a4ffafcea09601a2c32fa0201.mp3",
+ "data/jw-living-phrases/card_audio/the_mirrow_just_broke_Joey_a27783fe6baa471a4ffafcea09601a2c32fa0201.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²da²²ke³hdi² u²²ka²ha²da²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_296090_299340.mp3"
+ ],
+ "syllabary": "ᎠᏓᎨᏗ ᎤᎧᎭᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "The mirror is fogged up",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/the_mirror_is_fogged_up_Kendra_bb887f03e613bcb4ff728b3b5ac34dd60977a878.mp3",
+ "data/jw-living-phrases/card_audio/the_mirror_is_fogged_up_Joey_bb887f03e613bcb4ff728b3b5ac34dd60977a878.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²da²yv²³la,tv⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_309800_311070.mp3"
+ ],
+ "syllabary": "ᎤᏓᏴᎳᏛᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "his or her reflection",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/his_or_her_reflection_Kendra_81ab95e1a29af120fb5aec5e700d90652432c41b.mp3",
+ "data/jw-living-phrases/card_audio/his_or_her_reflection_Joey_81ab95e1a29af120fb5aec5e700d90652432c41b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²sdo²²ye³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_313170_314400.mp3"
+ ],
+ "syllabary": "ᎠᏍᏙᏰᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "he or she is shaving",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_shaving_Kendra_8236906359baec80f21b31144fadd65cccb6daa4.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_shaving_Joey_8236906359baec80f21b31144fadd65cccb6daa4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²ga²nv²²sge³³ni² da²sdo²²ye³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_319940_322850.mp3"
+ ],
+ "syllabary": "ᏗᎦᏅᏍᎨᏂ ᏓᏍᏙᏰᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "he or she is shaving his or her legs",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_shaving_his_or_her__Kendra_0261e164ddd13c7c9afb3e006e94bfa6c9e97e9f.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_shaving_his_or_her__Joey_0261e164ddd13c7c9afb3e006e94bfa6c9e97e9f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "da²ga²li³²sdo²²ye²²ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_325390_326760.mp3"
+ ],
+ "syllabary": "ᏓᎦᎵᏍᏙᏰᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m going to shave",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_going_to_shave_Kendra_e3991bd99c9c47a906a71a5a2389692e7513f2fb.mp3",
+ "data/jw-living-phrases/card_audio/im_going_to_shave_Joey_e3991bd99c9c47a906a71a5a2389692e7513f2fb.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²li²²sdo²²ye³³ʔo³hna²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_329810_331390.mp3"
+ ],
+ "syllabary": "ᎦᎵᏍᏙᏰᎣᎿ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I just now shaved",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_just_now_shaved_Kendra_005eafa21e6c7b59c6a1f03ac35cd05088c72cce.mp3",
+ "data/jw-living-phrases/card_audio/i_just_now_shaved_Joey_005eafa21e6c7b59c6a1f03ac35cd05088c72cce.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do²²yi, tsu²²ne²³da¹¹sdi² wa²²ya³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_335310_338350.mp3"
+ ],
+ "syllabary": "ᏙᏱ ᏧᏁᏓᏍᏗ ᏩᏯᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is in the bathroom",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_in_the_bathroom_Kendra_767699832a37baa2772965cd54c7d8218ab2fbf1.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_in_the_bathroom_Joey_767699832a37baa2772965cd54c7d8218ab2fbf1.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "o²²hla²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_340420_341080.mp3"
+ ],
+ "syllabary": "ᎣᎭᎳ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "soap",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/soap_Kendra_f6c1ab81815f784f6b910c1e4b08aaeeb145dd9e.mp3",
+ "data/jw-living-phrases/card_audio/soap_Joey_f6c1ab81815f784f6b910c1e4b08aaeeb145dd9e.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²sdu²³hli,do²hdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_343650_344940.mp3"
+ ],
+ "syllabary": "ᎠᏍᏚᎵᏙᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "shampoo",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/shampoo_Kendra_3906b34397ea49caba530fd8667b952f83451e40.mp3",
+ "data/jw-living-phrases/card_audio/shampoo_Joey_3906b34397ea49caba530fd8667b952f83451e40.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ta²ʔsu²³li²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_347500_348460.mp3"
+ ],
+ "syllabary": "ᏔᏍᏑᎵ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Wash your hands",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/wash_your_hands_Kendra_7087a6d73c85251f28c0e610f8b6102fdafa580a.mp3",
+ "data/jw-living-phrases/card_audio/wash_your_hands_Joey_7087a6d73c85251f28c0e610f8b6102fdafa580a.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ha²gv²²sgwo²²tsa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_350730_351890.mp3"
+ ],
+ "syllabary": "ᎭᎬᏍᏉᏣ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Wash your face",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/wash_your_face_Kendra_8945c16582d869e5932dd2dc1236be60bc0cb6cf.mp3",
+ "data/jw-living-phrases/card_audio/wash_your_face_Joey_8945c16582d869e5932dd2dc1236be60bc0cb6cf.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ha²li,sdu²³li²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_354900_356030.mp3"
+ ],
+ "syllabary": "ᎭᎵᏍᏚᎵ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Wash your hair",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/wash_your_hair_Kendra_56beb1aedea67126d356e3c552a15450b7597c2f.mp3",
+ "data/jw-living-phrases/card_audio/wash_your_hair_Joey_56beb1aedea67126d356e3c552a15450b7597c2f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ta²ʔda²nv²ʔsu²³li²²lo²³tsa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_358680_360410.mp3"
+ ],
+ "syllabary": "ᏔᏓᏅᏍᏑᎵᎶᏣ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Wash your feet",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/wash_your_feet_Kendra_71fe2deb85ac70827d55acd01b61746d69ed1704.mp3",
+ "data/jw-living-phrases/card_audio/wash_your_feet_Joey_71fe2deb85ac70827d55acd01b61746d69ed1704.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "he³³sdi, tsi²²tsv²²ke²²wa,s, di²²tsa²nv²²ga²hlv,di² ti²ʔle³³ni² o²hni,di³³tlv²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_364250_370610.mp3"
+ ],
+ "syllabary": "ᎮᏍᏗ ᏥᏨᎨᏩᏍᏏ ᏗᏣᏅᎦᎸᏗ ᏘᎴᏂ ᎣᏂᏗᏢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Don’t forget to wash behind your ears",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/dont_forget_to_wash_behind_your__Kendra_99d6097d9aa9e1157ef030da4b2f81500cbbf119.mp3",
+ "data/jw-living-phrases/card_audio/dont_forget_to_wash_behind_your__Joey_99d6097d9aa9e1157ef030da4b2f81500cbbf119.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "da²²su²³le³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_375980_377170.mp3"
+ ],
+ "syllabary": "ᏓᏍᏑᎴᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is washing his or her hands",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_washing_his_or_her__Kendra_7b36d95dd34aa44f0ee356d691f2cd235bdc3ee4.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_washing_his_or_her__Joey_7b36d95dd34aa44f0ee356d691f2cd235bdc3ee4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "de²²ga³³su²³le³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_380120_381700.mp3"
+ ],
+ "syllabary": "ᏕᎦᏍᏑᎴᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m washing my hands",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_washing_my_hands_Kendra_c4e86db0d91e675b344e8bde3bcd7a3815c54b3c.mp3",
+ "data/jw-living-phrases/card_audio/im_washing_my_hands_Joey_c4e86db0d91e675b344e8bde3bcd7a3815c54b3c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²²li,sdu²³le³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_386070_387400.mp3"
+ ],
+ "syllabary": "ᎠᎵᏍᏚᎴᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is washing his or her hair",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_washing_his_or_her__Kendra_7c4edb19ee1933a9818214ae6eb44807e16bcca8.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_washing_his_or_her__Joey_7c4edb19ee1933a9818214ae6eb44807e16bcca8.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²gv²²sgwo³³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_390390_391650.mp3"
+ ],
+ "syllabary": "ᎦᎬᏍᏉᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m washing my face",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_washing_my_face_Kendra_e195ef5ea5842486d33c32f7481952883b638211.mp3",
+ "data/jw-living-phrases/card_audio/im_washing_my_face_Joey_e195ef5ea5842486d33c32f7481952883b638211.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tla³ ya³gwv²²ke²²wa²da, di²²gi²nv²²ga²hlv,di² di²²tsi²ʔle³³ni² o²hni,di³³tlv²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_395110_401710.mp3"
+ ],
+ "syllabary": "Ꮭ ᏯᏋᎨᏩᏓ ᏗᎩᏅᎦᎸᏗ ᏗᏥᎴᏂ ᎣᏂᏗᏢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I didn’t forget to wash behind my ears",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_didnt_forget_to_wash_behind_my_Kendra_842895829103b2b42012911e165683aa702cb61b.mp3",
+ "data/jw-living-phrases/card_audio/i_didnt_forget_to_wash_behind_my_Joey_842895829103b2b42012911e165683aa702cb61b.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "dv²²ga²²nv,do³hgv⁴ di²²ga²nv²²ga²hlv,do²hdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_404170_407580.mp3"
+ ],
+ "syllabary": "ᏛᎦᏅᏙᎬ ᏗᎦᏅᎦᎸᏙᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "toothbrush",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/toothbrush_Kendra_c3cc7c6c532c8a0e28aeb33e0f9813b3ba1be65f.mp3",
+ "data/jw-living-phrases/card_audio/toothbrush_Joey_c3cc7c6c532c8a0e28aeb33e0f9813b3ba1be65f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "dv²²ga²²nv,do³hgv⁴ di²²ga²nv²²ga²hlv,do²hdi² o²²hla²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_410030_414520.mp3"
+ ],
+ "syllabary": "ᏛᎦᏅᏙᎬ ᏗᎦᏅᎦᎸᏙᏗ ᎣᎭᎳ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "toothpaste",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/toothpaste_Kendra_22ef5ba4d44a78205c392980e5c722f2f2f88727.mp3",
+ "data/jw-living-phrases/card_audio/toothpaste_Joey_22ef5ba4d44a78205c392980e5c722f2f2f88727.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ti²ʔsgwa²lv¹¹ya²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_416950_418060.mp3"
+ ],
+ "syllabary": "ᏘᏍᏆᎸᏯ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "You chop them (something long)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/you_chop_them_something_long_Kendra_c60ec0248a88b6389ec0794e91aee442a03eb048.mp3",
+ "data/jw-living-phrases/card_audio/you_chop_them_something_long_Joey_c60ec0248a88b6389ec0794e91aee442a03eb048.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²gv²²ha²lu³²ya²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_421770_422820.mp3"
+ ],
+ "syllabary": "ᏗᎬᎭᎷᏯ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He is chopping them (something flexible)",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_is_chopping_them_something_fl_Kendra_b739e409d2c8b628ec2ced01899a9289a1a5c2cc.mp3",
+ "data/jw-living-phrases/card_audio/he_is_chopping_them_something_fl_Joey_b739e409d2c8b628ec2ced01899a9289a1a5c2cc.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²dli¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_429430_430330.mp3"
+ ],
+ "syllabary": "ᎯᏟᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Pour it in",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/pour_it_in_Kendra_4ea9a8431019c7ccdd873f6bb4fb312359df6362.mp3",
+ "data/jw-living-phrases/card_audio/pour_it_in_Joey_4ea9a8431019c7ccdd873f6bb4fb312359df6362.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "nu²³na, ti²ne³³ga²la²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_425660_427180.mp3"
+ ],
+ "syllabary": "ᏄᎾ ᏘᏁᎦᎳ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Peel the potatoes",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/peel_the_potatoes_Kendra_218853baf5aa967dd65357cb1b7fe14cc933507f.mp3",
+ "data/jw-living-phrases/card_audio/peel_the_potatoes_Joey_218853baf5aa967dd65357cb1b7fe14cc933507f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do²²chi²la³³ni²tsu³ nu²³na²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_445440_448000.mp3"
+ ],
+ "syllabary": "ᏙᏥᎳᏂᏧ ᏄᎾ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Do you want me to put the potatoes in?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/do_you_want_me_to_put_the_potato_Kendra_d2c5f885d503aa481e0478134a4563c8f06f3c26.mp3",
+ "data/jw-living-phrases/card_audio/do_you_want_me_to_put_the_potato_Joey_d2c5f885d503aa481e0478134a4563c8f06f3c26.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a³³ma²dv³³ ga²lu²³lo³²ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_433050_435740.mp3"
+ ],
+ "syllabary": "ᎠᎹᏛ ᎦᎷᎶᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "This needs more salt",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/this_needs_more_salt_Kendra_5db4a9810cb7d9c664d36b59d6395546987eebb9.mp3",
+ "data/jw-living-phrases/card_audio/this_needs_more_salt_Joey_5db4a9810cb7d9c664d36b59d6395546987eebb9.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²ma² tsi²²te³³hdi²³ha²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_450890_452400.mp3"
+ ],
+ "syllabary": "ᎠᎹ ᏥᏖᏗᎭ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I'm boiling the water",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_boiling_the_water_Kendra_e35faa6ed7b351dc3ea86c9ccbf45d209844e3ac.mp3",
+ "data/jw-living-phrases/card_audio/im_boiling_the_water_Joey_e35faa6ed7b351dc3ea86c9ccbf45d209844e3ac.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sgwu³dv³³ tsi²ga²²ta² a¹¹gwv²²ni³sdo²hdi⁴³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_456300_459890.mp3"
+ ],
+ "syllabary": "ᏍᏊᏛ ᏥᎦᏔ ᎠᏋᏂᏍᏙᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Because that’s the only thing I know how to cook",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/because_thats_the_only_thing_i_k_Kendra_845878dede1202209550344f3a50e879eca88d38.mp3",
+ "data/jw-living-phrases/card_audio/because_thats_the_only_thing_i_k_Joey_845878dede1202209550344f3a50e879eca88d38.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do²²yu³ o⁴⁴sda, a²²sv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_462860_464350.mp3"
+ ],
+ "syllabary": "ᏙᏳ ᎣᏍᏓ ᎠᏍᏒᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "It smells really good",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/it_smells_really_good_Kendra_9ab55f52d85859bae2817fa3074e6ae4d60e1a93.mp3",
+ "data/jw-living-phrases/card_audio/it_smells_really_good_Joey_9ab55f52d85859bae2817fa3074e6ae4d60e1a93.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u⁴⁴sdi, na² tsa²²sv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_466920_468720.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᏍᏗ Ꮎ ᏣᏍᏒᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What is that smell?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/what_is_that_smell_Kendra_bd71a6b3a9e504952245c2b47942a71ac9d7f58a.mp3",
+ "data/jw-living-phrases/card_audio/what_is_that_smell_Joey_bd71a6b3a9e504952245c2b47942a71ac9d7f58a.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u⁴⁴sdi, e²³li³²sdi² a²²sv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_471320_473850.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᏍᏗ ᎡᎵᏍᏗ ᎠᏍᏒᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What does it smell like?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/what_does_it_smell_like_Kendra_784efef15917eb8f7e9ade4f1dd0b01ccd4c7113.mp3",
+ "data/jw-living-phrases/card_audio/what_does_it_smell_like_Joey_784efef15917eb8f7e9ade4f1dd0b01ccd4c7113.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sv²²ga,ta²wu, e²³li³²sdi, ga²²wa,sv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_476350_479750.mp3"
+ ],
+ "syllabary": "ᏍᏒᎦᏔᏭ ᎡᎵᏍᏗ ᎦᏩᏍᏒᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "It smells like apples",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/it_smells_like_apples_Kendra_84af46f3945235937c35730bf99bf0598cc7d943.mp3",
+ "data/jw-living-phrases/card_audio/it_smells_like_apples_Joey_84af46f3945235937c35730bf99bf0598cc7d943.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u⁴⁴sdi, hi²tlv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_483700_485100.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᏍᏗ ᎯᏢᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What did you put in this?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/what_did_you_put_in_this_Kendra_b74f35394a1cb55157ec0f2d6ae69fd822cf8fde.mp3",
+ "data/jw-living-phrases/card_audio/what_did_you_put_in_this_Joey_b74f35394a1cb55157ec0f2d6ae69fd822cf8fde.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u⁴⁴sdi, ha²su²²yv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_489330_490740.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᏍᏗ ᎭᏍᏑᏴᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What did you mix in this?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/what_did_you_mix_in_this_Kendra_346b25ee14493965eeb0e3b2a8ab45477bc66adb.mp3",
+ "data/jw-living-phrases/card_audio/what_did_you_mix_in_this_Joey_346b25ee14493965eeb0e3b2a8ab45477bc66adb.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sv¹¹gi² ta²ma²³tli²hnv³ de²²tsi³ʔlv¹¹ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_494610_499310.mp3"
+ ],
+ "syllabary": "ᏍᏒᎩ ᏔᎹᏟᏅ ᏕᏥᎸᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I put onions and tomatoes in it",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_put_onions_and_tomatoes_in_it_Kendra_355c87935d417494efbadb2326ea0ecfaff00a7d.mp3",
+ "data/jw-living-phrases/card_audio/i_put_onions_and_tomatoes_in_it_Joey_355c87935d417494efbadb2326ea0ecfaff00a7d.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²²tla²hnv²²di²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_501880_503100.mp3"
+ ],
+ "syllabary": "ᎠᏝᏅᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "pantry",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/pantry_Kendra_d2ea75fb11b48b53da2a9f32c1656b2691c05dd8.mp3",
+ "data/jw-living-phrases/card_audio/pantry_Joey_d2ea75fb11b48b53da2a9f32c1656b2691c05dd8.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_505690_507430.mp3"
+ ],
+ "syllabary": "ᎠᏴᏜᏗᏍᏙᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "fridge",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/fridge_Kendra_3a82c7a5e37077d52db198435e33225cfcbf0de6.mp3",
+ "data/jw-living-phrases/card_audio/fridge_Joey_3a82c7a5e37077d52db198435e33225cfcbf0de6.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "e²²li³³wu³ke³ tsu²²we²³tsi, yi²de³³hi²le²gi² a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_511530_516080.mp3"
+ ],
+ "syllabary": "ᎡᎵᏭᎨ ᏧᏪᏥ ᏱᏕᎯᎴᎩ ᎠᏴᏜᏗᏍᏙᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Can you get the eggs out of the fridge?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/can_you_get_the_eggs_out_of_the__Kendra_dd0fcf022465c122200e15a0bce9c2f136be5b47.mp3",
+ "data/jw-living-phrases/card_audio/can_you_get_the_eggs_out_of_the__Joey_dd0fcf022465c122200e15a0bce9c2f136be5b47.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²ʔa²nv³ hwi²²gi²hlv¹¹ga² a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_519170_523760.mp3"
+ ],
+ "syllabary": "ᎯᎠᏅ ᏫᎯᎩᏢᎦ ᎠᏴᏜᏗᏍᏙᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Put this back in the fridge",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/put_this_back_in_the_fridge_Kendra_d3110aa078612fff670393214b433e1b2aadf5f6.mp3",
+ "data/jw-living-phrases/card_audio/put_this_back_in_the_fridge_Joey_d3110aa078612fff670393214b433e1b2aadf5f6.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²ti²²yv²³hnv, da²tsi²²sgwa²²ni,go¹¹ta²ni²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_527350_530240.mp3"
+ ],
+ "syllabary": "ᎤᏘᏴᏅ ᏓᏥᏍᏆᏂᎪᏔᏂ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I have to put the leftovers away",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_have_to_put_the_leftovers_away_Kendra_9985b0312194b9a79f582a28f219455c2313193e.mp3",
+ "data/jw-living-phrases/card_audio/i_have_to_put_the_leftovers_away_Joey_9985b0312194b9a79f582a28f219455c2313193e.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "e²²li³³wu³ke³ yi²ki,sde²²la, di²gv²²di²²ye³ʔv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_533100_537049.mp3"
+ ],
+ "syllabary": "ᎡᎵᏭᎨ ᏱᏍᎩᏍᏕᎳ ᏗᎬᏗᏰᎥᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Can you help me with the dishes?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/can_you_help_me_with_the_dishes_Kendra_e4175c84ead2146e591b544ac7f09dddc157459f.mp3",
+ "data/jw-living-phrases/card_audio/can_you_help_me_with_the_dishes_Joey_e4175c84ead2146e591b544ac7f09dddc157459f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "e²²li³³wu³ke³ hyi²²sde²²la² de²²gv³³di²²ye³³sgv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_540630_544680.mp3"
+ ],
+ "syllabary": "ᎡᎵᏭᎨ ᏱᎯᏍᏕᎳ ᏕᎬᏗᏰᏍᎬᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Can you help him or her with the dishes?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/can_you_help_him_or_her_with_the_Kendra_7ec84f528f3fe3ef4b0b293d1d147e53d2e70181.mp3",
+ "data/jw-living-phrases/card_audio/can_you_help_him_or_her_with_the_Joey_7ec84f528f3fe3ef4b0b293d1d147e53d2e70181.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²hya²ta²na²²lv⁴ ga²nv²²ga²la²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_547730_550780.mp3"
+ ],
+ "syllabary": "ᎠᏯᏔᎾᎸ ᎦᏅᎦᎳ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she cleaned the counter",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_cleaned_the_counter_Kendra_4d0112307e1ebd68506309949681ddc619d1c234.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_cleaned_the_counter_Joey_4d0112307e1ebd68506309949681ddc619d1c234.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "wa²²gi²lv²³gwo,dv² te²²li,do³ tsi²²yo³³sda²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_554500_558660.mp3"
+ ],
+ "syllabary": "ᏩᎩᎸᏉᏛ ᏖᎵᏙ ᏥᏲᏍᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I just broke my favorite plate",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_just_broke_my_favorite_plate_Kendra_9139bf50488d64b65c7a568952e1061b1950b4ab.mp3",
+ "data/jw-living-phrases/card_audio/i_just_broke_my_favorite_plate_Joey_9139bf50488d64b65c7a568952e1061b1950b4ab.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²li²²sgwi³ʔdi² e²²skv²ʔsi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_561190_564170.mp3"
+ ],
+ "syllabary": "ᎤᎵᏍᏈᏗ ᎡᏍᎬᏍᏏ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Hand me a cup",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/hand_me_a_cup_Kendra_2c47d085542b97837c78ae098c19e3878839fede.mp3",
+ "data/jw-living-phrases/card_audio/hand_me_a_cup_Joey_2c47d085542b97837c78ae098c19e3878839fede.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "go²²hu⁴⁴sdi²s tsa²du²²li³ tsa²di³³ta²sdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_567170_569490.mp3"
+ ],
+ "syllabary": "ᎪᎱᏍᏗᏍ ᏣᏚᎵ ᏣᏗᏔᏍᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Do you want anything to drink?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/do_you_want_anything_to_drink_Kendra_54b28f77194c5899eeda12c94a2de0acf0508054.mp3",
+ "data/jw-living-phrases/card_audio/do_you_want_anything_to_drink_Joey_54b28f77194c5899eeda12c94a2de0acf0508054.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ka²³wi² a¹¹gwa²du²²li³ha²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_571880_573320.mp3"
+ ],
+ "syllabary": "ᎧᏫ ᎠᏆᏚᎵᎭ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I want some coffee",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_want_some_coffee_Kendra_4f06bb1f9aa756dbe736c4f52937ab515b9774e3.mp3",
+ "data/jw-living-phrases/card_audio/i_want_some_coffee_Joey_4f06bb1f9aa756dbe736c4f52937ab515b9774e3.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tsa²³yi² u²dli³ na²²gi²lv²³gwo,di, ka²³wi² ni²ge³³sv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_577090_582060.mp3"
+ ],
+ "syllabary": "ᏣᏱ ᎤᏟ ᎾᎩᎸᏉᏗ ᎧᏫ ᏂᎨᏍᏒᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I like tea better than coffee",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_like_tea_better_than_coffee_Kendra_8f2e642e76df05a295a43efb6b0f22a55f8cc6db.mp3",
+ "data/jw-living-phrases/card_audio/i_like_tea_better_than_coffee_Joey_8f2e642e76df05a295a43efb6b0f22a55f8cc6db.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²²sgi²di³²si, na² a²ga²²ha²lu³²ya,sdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_585170_588830.mp3"
+ ],
+ "syllabary": "ᏗᏍᎩᏗᏍᏏ Ꮎ ᎠᎦᎭᎷᏯᏍᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Hand me the cutting board",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/hand_me_the_cutting_board_Kendra_399abf672c350f427398e10f4acf26d794c8e6d6.mp3",
+ "data/jw-living-phrases/card_audio/hand_me_the_cutting_board_Joey_399abf672c350f427398e10f4acf26d794c8e6d6.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ha²ye²²la,sdi² di²²sgi²di³²si,",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_592410_594470.mp3"
+ ],
+ "syllabary": "ᎭᏰᎳᏍᏗ ᏗᏍᎩᏗᏍᏏ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Hand me a knife",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/hand_me_a_knife_Kendra_902d4166298c97ff585e2752746315a4a6757a89.mp3",
+ "data/jw-living-phrases/card_audio/hand_me_a_knife_Joey_902d4166298c97ff585e2752746315a4a6757a89.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²²sda²ga²²yv,da² ga³³du²ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_597350_599670.mp3"
+ ],
+ "syllabary": "ᎯᏍᏓᎦᏴᏓ ᎦᏚᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Toast the bread",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/toast_the_bread_Kendra_aa3b0ea9856a0a12130f765b45a921c606b8d1f9.mp3",
+ "data/jw-living-phrases/card_audio/toast_the_bread_Joey_aa3b0ea9856a0a12130f765b45a921c606b8d1f9.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "go²²hlv²²nv² ga²nv²²li²³ye³ʔa² ga³³du² a²²sda²ga²²yv,ta²nv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_602920_607480.mp3"
+ ],
+ "syllabary": "ᎪᏢᏅ ᎦᏅᎵᏰᎠ ᎦᏚ ᎠᏍᏓᎦᏴᏔᏅᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is buttering the toast",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_buttering_the_toast_Kendra_fefefc0bb66f58d1df97c73cf679e1d71eccd674.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_buttering_the_toast_Joey_fefefc0bb66f58d1df97c73cf679e1d71eccd674.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tsa²du²²li³³s go²²hlv²²nv² ga²nv²²li²³ye³³da² ga³³du² a²²sda²ga³³yv,ta²nv²³ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_616150_621630.mp3"
+ ],
+ "syllabary": "ᏣᏚᎵᏍ ᎪᏢᏅ ᎦᏅᎵᏰᏓ ᎦᏚ ᎠᏍᏓᎦᏴᏔᏅᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Do you want butter on your toast?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/do_you_want_butter_on_your_toast_Kendra_b7b18307f9ebd1808aa28729462c65797ce78bdd.mp3",
+ "data/jw-living-phrases/card_audio/do_you_want_butter_on_your_toast_Joey_b7b18307f9ebd1808aa28729462c65797ce78bdd.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²gv²²di²²ye³³sgi² da²²tsi²le³³sv²²hni²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_625210_628460.mp3"
+ ],
+ "syllabary": "ᏗᎬᏗᏰᏍᎩ ᏓᏥᎴᏒᏂ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m going to empty the dishwasher",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_going_to_empty_the_dishwasher_Kendra_3047b5c0862c14419903d53a4e0895813b96e762.mp3",
+ "data/jw-living-phrases/card_audio/im_going_to_empty_the_dishwasher_Joey_3047b5c0862c14419903d53a4e0895813b96e762.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ni²ga⁴⁴da, ti²le³³sv²²hv¹¹ga² di²gv²²di²²ye³³sgi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_632390_637120.mp3"
+ ],
+ "syllabary": "ᏂᎦᏓ ᏘᎴᏍᏒᎲᎦ ᏗᎬᏗᏰᏍᎩ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Empty the dishwasher",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/empty_the_dishwasher_Kendra_4d9e27f0233f11efa507a393894730ecc8e91370.mp3",
+ "data/jw-living-phrases/card_audio/empty_the_dishwasher_Joey_4d9e27f0233f11efa507a393894730ecc8e91370.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "di²gv²²di²²ye³³sgi² ga²²hla²nv³sga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_641280_644210.mp3"
+ ],
+ "syllabary": "ᏗᎬᏗᏰᏍᎩ ᎦᎳᏅᏍᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is loading the dishwasher",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_loading_the_dishwas_Kendra_62e9a69bbc116923b2ce6e55d191383f46fb63c6.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_loading_the_dishwas_Joey_62e9a69bbc116923b2ce6e55d191383f46fb63c6.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ni²ga⁴⁴da, de²²tsi³le³²sv²²sgo³ di²gv²²di²²ye³³sgi² sa²na³³le⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_648190_653610.mp3"
+ ],
+ "syllabary": "ᏂᎦᏓ ᏕᏥᎴᏍᏒᏍᎪ ᏗᎬᏗᏰᏍᎩ ᏍᏌᎾᎴᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I always empty the dishwasher in the morning",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_always_empty_the_dishwasher_in_Kendra_63b1b5046e44ab721bfec10c84867b931be065a4.mp3",
+ "data/jw-living-phrases/card_audio/i_always_empty_the_dishwasher_in_Joey_63b1b5046e44ab721bfec10c84867b931be065a4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ka²³wi²s a¹¹gwo²²tlv²hdi² tsa²du²²li³³ha²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_664670_667660.mp3"
+ ],
+ "syllabary": "ᎧᏫᏍ ᎠᏉᏢᏗ ᏣᏚᎵᎭ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Do you want me to make some coffee?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/do_you_want_me_to_make_some_coff_Kendra_6e93c546b36d87d819cbfe2f75d0d58e1cc4a6c4.mp3",
+ "data/jw-living-phrases/card_audio/do_you_want_me_to_make_some_coff_Joey_6e93c546b36d87d819cbfe2f75d0d58e1cc4a6c4.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "da²go²²tlv²²hni²ke³ ka²³wi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_657600_659420.mp3"
+ ],
+ "syllabary": "ᏓᎪᏢᏂᎨ ᎧᏫ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Am I to make coffee?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/am_i_to_make_coffee_Kendra_ecb8fbfb2821804847c869bc60615562c0030a6a.mp3",
+ "data/jw-living-phrases/card_audio/am_i_to_make_coffee_Joey_ecb8fbfb2821804847c869bc60615562c0030a6a.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u²²li,sta²nv² ka²³wi, a²tli²²sdo²hdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_670420_673040.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᎵᏍᏔᏅ ᎧᏫ ᎠᏟᏍᏙᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What’s wrong with the coffee pot?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/whats_wrong_with_the_coffee_pot_Kendra_f65f853ed7e7fbf7b315efc6466f32c351213238.mp3",
+ "data/jw-living-phrases/card_audio/whats_wrong_with_the_coffee_pot_Joey_f65f853ed7e7fbf7b315efc6466f32c351213238.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²yo³²tsv²³dv³ ge²²li³ʔa² a²tse³dv³³ a²²se³ a¹¹ki²wa²hi²sdi² ge²²se³³sdi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_677280_684880.mp3"
+ ],
+ "syllabary": "ᎤᏲᏨᏛ ᎨᎵᎠ. ᎠᏤᏛ ᎠᏍᏎ ᎠᎩᏩᎯᏍᏗ ᎨᏍᏎᏍᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I think it’s broke. I’ll have to buy a new one",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_think_its_broke_ill_have_to_bu_Kendra_35ea924b9b03c7e1b6805c2cd4aa1ae0a128e698.mp3",
+ "data/jw-living-phrases/card_audio/i_think_its_broke_ill_have_to_bu_Joey_35ea924b9b03c7e1b6805c2cd4aa1ae0a128e698.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga³³go, tsu²²tse²²li³ di²²te²li²²do⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_687200_689440.mp3"
+ ],
+ "syllabary": "ᎦᎪ ᏧᏤᎵ ᏗᏖᎵᏙᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Whose dishes are those?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/whose_dishes_are_those_Kendra_35ddbc9b78833981bde9ce9ea68181b575d085cb.mp3",
+ "data/jw-living-phrases/card_audio/whose_dishes_are_those_Joey_35ddbc9b78833981bde9ce9ea68181b575d085cb.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²²da²yv²³la,tv²sgi² da²tsi²²ga²²to²³sta²ni²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_692840_696590.mp3"
+ ],
+ "syllabary": "ᎠᏓᏴᎳᏛᏍᎩ ᏓᏥᎦᏙᏍᏔᏂ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I’m going to watch television",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/im_going_to_watch_television_Kendra_20d5b6789f0a71b77f4a1c4617afbbb08b0f2baf.mp3",
+ "data/jw-living-phrases/card_audio/im_going_to_watch_television_Joey_20d5b6789f0a71b77f4a1c4617afbbb08b0f2baf.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "tsa²du²²li³³s gu⁴⁴sdi² hyi²ga²²to²³sdo²hdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_699410_702660.mp3"
+ ],
+ "syllabary": "ᏣᏚᎵᏍ ᎫᏍᏗ ᏱᎯᎦᏙᏍᏙᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "Do you want to go watch something?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/do_you_want_to_go_watch_somethin_Kendra_d173c2bc6c040919c46a30e06e1d8301692cc007.mp3",
+ "data/jw-living-phrases/card_audio/do_you_want_to_go_watch_somethin_Joey_d173c2bc6c040919c46a30e06e1d8301692cc007.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "hi²la² du²²ga²²nv,da³ʔdv⁴ di²²tsa²go²²li²³ye³³da²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_720710_723660.mp3"
+ ],
+ "syllabary": "ᎯᎳ ᏚᎦᏅᏓᏛ ᏗᏣᎪᎵᏰᏓ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "How many pages have you read?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/how_many_pages_have_you_read_Kendra_15b306b455ada5f8a6eac8d7474d0259bb800a1e.mp3",
+ "data/jw-living-phrases/card_audio/how_many_pages_have_you_read_Joey_15b306b455ada5f8a6eac8d7474d0259bb800a1e.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do² u⁴⁴sdi, hi²go²²li²³ye³ʔa²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_704680_706810.mp3"
+ ],
+ "syllabary": "Ꮩ ᎤᏍᏗ ᎯᎪᎵᏰᎠ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "What are you reading?",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/what_are_you_reading_Kendra_f57d5b06475a3ff00985fcbdcb689ac7b2aea1cf.mp3",
+ "data/jw-living-phrases/card_audio/what_are_you_reading_Joey_f57d5b06475a3ff00985fcbdcb689ac7b2aea1cf.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "sgi²²ga²du² du²²ga²²nv,da³ʔdv⁴ de²²tsi³go²²li²³ye³ʔa² tsu²²na,de²³hlo,gwa¹¹sdi² u²²gv²³wa²hli²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_710190_717080.mp3"
+ ],
+ "syllabary": "ᏍᎩᎦᏚ ᏚᎦᏅᏓᏛ ᏕᏥᎪᎵᏰᎠ ᏧᎾᏕᏠᏆᏍᏗ ᎤᎬᏩᎵ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "I read fifteen pages for school",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/i_read_fifteen_pages_for_school_Kendra_9477ca83ed26e0bb7f3175e9da2854868bc7707f.mp3",
+ "data/jw-living-phrases/card_audio/i_read_fifteen_pages_for_school_Joey_9477ca83ed26e0bb7f3175e9da2854868bc7707f.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "u²²ne²²gv²²ha⁴ u²²du³³hlv²²di²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_727190_730060.mp3"
+ ],
+ "syllabary": "ᎤᏁᎬᎭ ᎤᏚᏢᏗ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is under a blanket",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_under_a_blanket_Kendra_bf47a24becc765338393994ba899bc7cd279ba7c.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_under_a_blanket_Joey_bf47a24becc765338393994ba899bc7cd279ba7c.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "o⁴⁴sda, u²²da²²nv,ta²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_733450_734790.mp3"
+ ],
+ "syllabary": "ᎣᏍᏓ ᎤᏓᏅᏔ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is comfortable",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_comfortable_Kendra_8b676ab621199842d88dc4e316e6d0319306fa05.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_comfortable_Joey_8b676ab621199842d88dc4e316e6d0319306fa05.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²²sgi²lo³³gi² u²²wo²³tla²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_738570_740910.mp3"
+ ],
+ "syllabary": "ᎦᏍᎩᎶᎩ ᎤᏬᏝ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is sitting on the couch",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_sitting_on_the_couc_Kendra_e45df131a2ea17516f0b06308657ff679d3ab3a1.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_sitting_on_the_couc_Joey_e45df131a2ea17516f0b06308657ff679d3ab3a1.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²²sgi²lo³³gi² ga²²nv,ga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_744090_745800.mp3"
+ ],
+ "syllabary": "ᎦᏍᎩᎶᎩ ᎦᏅᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is lying down on the couch",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_lying_down_on_the_c_Kendra_588fc4a9cd86a18070cc1fff0bfe0db59b715c2e.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_lying_down_on_the_c_Joey_588fc4a9cd86a18070cc1fff0bfe0db59b715c2e.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "a²yv³²sdi⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_762210_763360.mp3"
+ ],
+ "syllabary": "ᎠᏴᏍᏗᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "living room",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/living_room_Kendra_122604decb5a6e3989745b352da8004cf940fb21.mp3",
+ "data/jw-living-phrases/card_audio/living_room_Joey_122604decb5a6e3989745b352da8004cf940fb21.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "do²²yi³³tlv⁴⁴ʔi²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_747570_748720.mp3"
+ ],
+ "syllabary": "ᏙᏱᏢᎢ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "porch",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/porch_Kendra_2708dfee202dc4ffbf291591026d7b5075c52dea.mp3",
+ "data/jw-living-phrases/card_audio/porch_Joey_2708dfee202dc4ffbf291591026d7b5075c52dea.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²²sdlv³sga² a²²su²²lo³",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_752730_755200.mp3"
+ ],
+ "syllabary": "ᎦᏍᏢᏍᎦ ᎠᏍᏑᎶ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is patching a pair of pants",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_patching_a_pair_of__Kendra_9d6a9278475e461591668af31955d57cec4ac86d.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_patching_a_pair_of__Joey_9d6a9278475e461591668af31955d57cec4ac86d.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ },
+ {
+ "cherokee": "ga²²ye²²wi,sga²",
+ "cherokee_audio": [
+ "data/jw-living-phrases/card_audio/split_audio_758580_759670.mp3"
+ ],
+ "syllabary": "ᎦᏰᏫᏍᎦ",
+ "alternate_pronunciations": [],
+ "alternate_syllabary": [],
+ "english": "He or she is sewing",
+ "english_audio": [
+ "data/jw-living-phrases/card_audio/he_or_she_is_sewing_Kendra_6317a85dd7f49c98d6af2854a722e34d16a19c10.mp3",
+ "data/jw-living-phrases/card_audio/he_or_she_is_sewing_Joey_6317a85dd7f49c98d6af2854a722e34d16a19c10.mp3"
+ ],
+ "phoneticOrthography": "WEBSTER"
+ }
+]
diff --git a/src/data/collections/jw-living-phrases-credits.json b/src/data/collections/jw-living-phrases-credits.json
new file mode 100644
index 00000000..537d9958
--- /dev/null
+++ b/src/data/collections/jw-living-phrases-credits.json
@@ -0,0 +1,19 @@
+{
+ "credits": [
+ { "role": "Speaker", "name": "JW Webster" },
+ { "role": "Translator", "name": "JW Webster" }
+ ],
+ "description": "This collection was created for a student of JW's based on word they thought would be useful around the house. Terms are broken up by the most associated room in the house where they would be used.",
+ "externalResources": [
+ {
+ "name": "JW's website",
+ "notes": "You can schedule one-on-one meetings or sign up for a regular class. All levels welcome.",
+ "href": "https://thinkcherokee.com/"
+ },
+ {
+ "name": "Think Cherokee: A Cherokee Language Student Reference",
+ "notes": "JW's lastest book, a Cherokee Language Student Reference is designed to assist intermediate to advanced second-language learners with tone, syntax, morphology, and word creation.",
+ "href": "https://www.amazon.com/Think-Cherokee-Language-Student-Reference/dp/B0BV4LBSB4/"
+ }
+ ]
+}
diff --git a/src/data/collections/jw-living-phrases.json b/src/data/collections/jw-living-phrases.json
new file mode 100644
index 00000000..dc03ea6c
--- /dev/null
+++ b/src/data/collections/jw-living-phrases.json
@@ -0,0 +1,155 @@
+{
+ "id": "jw-living-phrases",
+ "title": "Living phrases",
+ "sets": [
+ {
+ "id": "jw-living-phrases:Bedroom",
+ "title": "Bedroom",
+ "terms": [
+ "ga²²da²²ha⁴",
+ "a²si²²nv,di² ka²nv³³su²²lv⁴",
+ "ga²ni²³hli²",
+ "o⁴⁴sda² ha,nv¹¹ga² ga²ni²³hli²",
+ "ti²ne³³dli²²yv¹¹na² di²²kv³³hi²da²",
+ "de²²tsi³ne³²dli²²yv²²na² di²²kv³³hi²da² ko²²hi² sa²na³³le⁴",
+ "a²kwi,sdo³",
+ "hwi²tlv²³na²",
+ "hi²tlv²³na²",
+ "o²²si²²tsu³ hi²tlv²²na²",
+ "di²²kv³³hi²da²",
+ "u²²ne²²gv²²ha⁴",
+ "u²²ne²²gv²²ha⁴ hi²²yv³hgwi²do²²si¹¹ya²",
+ "so³ʔi² u²²ne²²gv²²ha⁴ di²²sgi²nv²ʔsi²",
+ "sgo²³hi, yu³³tli²²lo³³da, tsi²ʔlv²²na, u²sv² tsi²ge²²sv² ",
+ "hi²la² i²³go³³hi⁴⁴da, hi²hlv²²na, u²sv² tsi²ge²²sv²",
+ "hi²la² i²³go³³hi⁴⁴da, hi²hlv²²na²",
+ "hi²la³²yv⁴ ha²di²²da²",
+ "hi²la³²yv⁴ tsa²di²²dv²²he³ sv²²hi² tsi²ge²²sv²³ʔi²",
+ "hi²la³²yv⁴ wi²tsa²²na,si²ne³³ʔi² u²sv² tsi²ge²²sv²",
+ "hi²sgi² tsi²ge²²sv² tsa²²hli²²ʔi³li²²sv² tsa²²gi²ye³³tsv² ko²²hi, sa²na³³le⁴⁴ʔi²",
+ "tsi²wu²²nv,si²nv²³ʔi²",
+ "de²²tsa³ya²we³³ga²s",
+ "hi²hlv²²sga²s",
+ "do²²yu³ da²²gi²²ya²we³³ga,",
+ "hv²htla²da² a²tsv²³ya,sdi²",
+ "hi²tsv²²ya,sdv¹¹ga²",
+ "di²²ya,to³³la,dv²³di² ti²²sdu²ʔi²",
+ "di²²ya,to³³la,dv²³di² de²²hi³sdu²²hv¹¹ga²",
+ "go²²hwe²³lo³²di² ga²²sgi²lo³",
+ "go²²hwe²³lo³²di² ga²²sgi²lo³ wu²²wo²³tla²",
+ "a²da²²hye²²sdi²³sgi²",
+ "wa²³tsi² a²da²²hye²²sdi²³sgi, u¹¹no²²hyv³hga²",
+ "wa²³tsi² a²da²²hye²²sdi²³sgi, a¹¹gwa²tse²²li³ su²³da³li, ge²²sv² u¹¹no²²hyv³hgo³³ʔi²"
+ ]
+ },
+ {
+ "id": "jw-living-phrases:Bathroom",
+ "title": "Bathroom",
+ "terms": [
+ "ga³³go, yu²²wa²³tsi² a²da²²hye²²sdi²³sgi, u¹¹no²²hyv³hga²",
+ "de²²hi³na,do³hgv⁴ ti²nv²²ga²la²",
+ "de²²tsi³na²²do³hgv⁴ da²²gi²nv²²ga²lv²²hv²³ʔi²",
+ "de²²tsi³na²²do³hgv⁴ de²²tsi³nv²²ga²li²²sgo³³ʔi² ni²go²³lv⁴",
+ "de²²tsi³³na²²do³hgv⁴ da²²tsi²nv²²ga²lv²²li, no²³gwu, tsi³gi²",
+ "de²²tsi³na²²do³hgv⁴ de²²tsi³nv²²ga²lv²²ʔe³³ga²",
+ "de²²hi³nv²²ga²le³³s de²²hi³na,do³hgv⁴ ko²²hi, sa²na³³le⁴",
+ "de²²hi³na,do³hgv⁴ de²²hi³nv²²ga²lv²²hv² ha²li,sda¹¹yv,hno³nv²³ʔi²",
+ "ki²la²² ga²da²wo³³ʔo³hna²",
+ "a¹¹gwa²da²wo²²sdi² nu⁴⁴sdi²",
+ "a²da²wo²²sdi⁴⁴ʔi²",
+ "tsu²²ni²²ga,sdi⁴⁴ʔi²",
+ "di²²su²³hlo,do²hdi²",
+ "a²da²²ke³hdi²",
+ "a²da²²ke³hdi² a²²yo³³gi²",
+ "a²da²²ke³hdi² u²²ka²ha²da²",
+ "u²²da²yv²³la,tv⁴⁴ʔi²",
+ "a²sdo²²ye³³ʔa²",
+ "di²²ga²nv²²sge³³ni² da²sdo²²ye³³ʔa²",
+ "da²ga²li³²sdo²²ye²²ʔi²",
+ "ga²li²²sdo²²ye³³ʔo³hna²",
+ "do²²yi, tsu²²ne²³da¹¹sdi² wa²²ya³ʔa²",
+ "o²²hla²",
+ "a²sdu²³hli,do²hdi²",
+ "ta²ʔsu²³li²",
+ "ha²gv²²sgwo²²tsa²",
+ "ha²li,sdu²³li²",
+ "ta²ʔda²nv²ʔsu²³li²²lo²³tsa²",
+ "he³³sdi, tsi²²tsv²²ke²²wa,s, di²²tsa²nv²²ga²hlv,di² ti²ʔle³³ni² o²hni,di³³tlv²",
+ "da²²su²³le³³ʔa²",
+ "de²²ga³³su²³le³³ʔa²",
+ "a²²li,sdu²³le³³ʔa²",
+ "ga²gv²²sgwo³³ʔa²",
+ "tla³ ya³gwv²²ke²²wa²da, di²²gi²nv²²ga²hlv,di² di²²tsi²ʔle³³ni² o²hni,di³³tlv²",
+ "dv²²ga²²nv,do³hgv⁴ di²²ga²nv²²ga²hlv,do²hdi²",
+ "dv²²ga²²nv,do³hgv⁴ di²²ga²nv²²ga²hlv,do²hdi² o²²hla²"
+ ]
+ },
+ {
+ "id": "jw-living-phrases:Kitchen",
+ "title": "Kitchen",
+ "terms": [
+ "ti²ʔsgwa²lv¹¹ya²",
+ "di²gv²²ha²lu³²ya²",
+ "hi²dli¹¹ga²",
+ "nu²³na, ti²ne³³ga²la²",
+ "do²²chi²la³³ni²tsu³ nu²³na²",
+ "a³³ma²dv³³ ga²lu²³lo³²ga²",
+ "a²ma² tsi²²te³³hdi²³ha²",
+ "sgwu³dv³³ tsi²ga²²ta² a¹¹gwv²²ni³sdo²hdi⁴³ʔi²",
+ "do²²yu³ o⁴⁴sda, a²²sv¹¹ga²",
+ "do² u⁴⁴sdi, na² tsa²²sv¹¹ga²",
+ "do² u⁴⁴sdi, e²³li³²sdi² a²²sv¹¹ga²",
+ "sv²²ga,ta²wu, e²³li³²sdi, ga²²wa,sv¹¹ga²",
+ "do² u⁴⁴sdi, hi²tlv¹¹ga²",
+ "do² u⁴⁴sdi, ha²su²²yv¹¹ga²",
+ "sv¹¹gi² ta²ma²³tli²hnv³ de²²tsi³ʔlv¹¹ga²",
+ "a²²tla²hnv²²di²",
+ "a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "e²²li³³wu³ke³ tsu²²we²³tsi, yi²de³³hi²le²gi² a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "hi²ʔa²nv³ hwi²²gi²hlv¹¹ga² a²hyv²²dla²di²²sdo²hdi⁴⁴ʔi²",
+ "u²²ti²²yv²³hnv, da²tsi²²sgwa²²ni,go¹¹ta²ni²",
+ "e²²li³³wu³ke³ yi²ki,sde²²la, di²gv²²di²²ye³ʔv²³ʔi²",
+ "e²²li³³wu³ke³ hyi²²sde²²la² de²²gv³³di²²ye³³sgv²³ʔi²",
+ "a²hya²ta²na²²lv⁴ ga²nv²²ga²la²",
+ "wa²²gi²lv²³gwo,dv² te²²li,do³ tsi²²yo³³sda²",
+ "u²²li²²sgwi³ʔdi² e²²skv²ʔsi²",
+ "go²²hu⁴⁴sdi²s tsa²du²²li³ tsa²di³³ta²sdi²",
+ "ka²³wi² a¹¹gwa²du²²li³ha²",
+ "tsa²³yi² u²dli³ na²²gi²lv²³gwo,di, ka²³wi² ni²ge³³sv²³ʔi²",
+ "di²²sgi²di³²si, na² a²ga²²ha²lu³²ya,sdi⁴⁴ʔi²",
+ "ha²ye²²la,sdi² di²²sgi²di³²si,",
+ "hi²²sda²ga²²yv,da² ga³³du²ʔi²",
+ "go²²hlv²²nv² ga²nv²²li²³ye³ʔa² ga³³du² a²²sda²ga²²yv,ta²nv²³ʔi²",
+ "tsa²du²²li³³s go²²hlv²²nv² ga²nv²²li²³ye³³da² ga³³du² a²²sda²ga³³yv,ta²nv²³ʔi²",
+ "di²gv²²di²²ye³³sgi² da²²tsi²le³³sv²²hni²",
+ "ni²ga⁴⁴da, ti²le³³sv²²hv¹¹ga² di²gv²²di²²ye³³sgi²",
+ "di²gv²²di²²ye³³sgi² ga²²hla²nv³sga²",
+ "ni²ga⁴⁴da, de²²tsi³le³²sv²²sgo³ di²gv²²di²²ye³³sgi² sa²na³³le⁴⁴ʔi²",
+ "ka²³wi²s a¹¹gwo²²tlv²hdi² tsa²du²²li³³ha²",
+ "da²go²²tlv²²hni²ke³ ka²³wi²",
+ "do² u²²li,sta²nv² ka²³wi, a²tli²²sdo²hdi²",
+ "u²²yo³²tsv²³dv³ ge²²li³ʔa² a²tse³dv³³ a²²se³ a¹¹ki²wa²hi²sdi² ge²²se³³sdi²",
+ "ga³³go, tsu²²tse²²li³ di²²te²li²²do⁴⁴ʔi²"
+ ]
+ },
+ {
+ "id": "jw-living-phrases:Living room",
+ "title": "Living room",
+ "terms": [
+ "a²²da²yv²³la,tv²sgi² da²tsi²²ga²²to²³sta²ni²",
+ "tsa²du²²li³³s gu⁴⁴sdi² hyi²ga²²to²³sdo²hdi⁴⁴ʔi²",
+ "hi²la² du²²ga²²nv,da³ʔdv⁴ di²²tsa²go²²li²³ye³³da²",
+ "do² u⁴⁴sdi, hi²go²²li²³ye³ʔa²",
+ "sgi²²ga²du² du²²ga²²nv,da³ʔdv⁴ de²²tsi³go²²li²³ye³ʔa² tsu²²na,de²³hlo,gwa¹¹sdi² u²²gv²³wa²hli²",
+ "u²²ne²²gv²²ha⁴ u²²du³³hlv²²di²",
+ "o⁴⁴sda, u²²da²²nv,ta²",
+ "ga²²sgi²lo³³gi² u²²wo²³tla²",
+ "ga²²sgi²lo³³gi² ga²²nv,ga²",
+ "a²yv³²sdi⁴⁴ʔi²",
+ "do²²yi³³tlv⁴⁴ʔi²",
+ "ga²²sdlv³sga² a²²su²²lo³",
+ "ga²²ye²²wi,sga²"
+ ]
+ }
+ ]
+}
diff --git a/src/data/collections/ssw-cards.json b/src/data/collections/ssw-cards.json
index 4f4cfa9f..c2d2cf76 100644
--- a/src/data/collections/ssw-cards.json
+++ b/src/data/collections/ssw-cards.json
@@ -1873,7 +1873,7 @@
"source/en/the_time_is_nine_thirty_Kendra_f9b953bddd22b5dce5879155b2762b2d26390855.mp3",
"source/en/the_time_is_nine_thirty_Joey_f9b953bddd22b5dce5879155b2762b2d26390855.mp3"
],
- "syllabary": "ᏐᏁᎳ ᎠᏰᏟ ᎠᏟᎵᎢ"
+ "syllabary": "ᏐᏁᎳ ᎠᏰᏟ ᎠᏟᎢᎵ"
},
{
"alternate_pronunciations": [],
@@ -1934,7 +1934,7 @@
{
"alternate_pronunciations": [],
"alternate_syllabary": [],
- "cherokee": "Gwiligi dù:do:ɂa achű:ja.",
+ "cherokee": "Gwiligi dù:do:ɂa hiɂa achű:ja.",
"cherokee_audio": [
"online-exercises-audio/0813-Lessons51_to_60-000324475.mp3"
],
@@ -2125,7 +2125,7 @@
"source/en/he_or_she_is_wearing_a_green_shi_Kendra_e6b9782fe126a94c5774d14a0021c89c39778a0b.mp3",
"source/en/he_or_she_is_wearing_a_green_shi_Joey_e6b9782fe126a94c5774d14a0021c89c39778a0b.mp3"
],
- "syllabary": "ᎢᏤᏳᏍᏗ ᎤᎵᏑᏫᏓ ᎤᏄᏩ"
+ "syllabary": "ᎢᏤᎢᏳᏍᏗ ᎤᎵᏑᏫᏓ ᎤᏄᏩ"
},
{
"alternate_pronunciations": [],
@@ -2279,7 +2279,7 @@
"source/en/lets_go_hunt_for_some_wild_onion_Kendra_c4692ded79f74a6c0a58b92e9ecd07973eff7dad.mp3",
"source/en/lets_go_hunt_for_some_wild_onion_Joey_c4692ded79f74a6c0a58b92e9ecd07973eff7dad.mp3"
],
- "syllabary": "ᏒᎩ ᎢᎾᎨᎡᎯ ᎩᏂᏯᎷᎦ"
+ "syllabary": "ᏒᎩ ᎢᎾᎨ ᎡᎯ ᎩᏂᏯᎷᎦ"
},
{
"alternate_pronunciations": [],
@@ -2335,7 +2335,7 @@
"source/en/i_hear_a_pigeon_cooing_Kendra_b1ab83bef414826a3fe272bee5a1b6c78c4b9a50.mp3",
"source/en/i_hear_a_pigeon_cooing_Joey_b1ab83bef414826a3fe272bee5a1b6c78c4b9a50.mp3"
],
- "syllabary": "ᏬᏱ ᏂᎦᏁᏍᎬ ᎦᏛᎩᎠ"
+ "syllabary": "ᏬᏱ ᏂᎦᏪᏍᎬ ᎦᏛᎩᎠ"
},
{
"alternate_pronunciations": [],
@@ -2382,7 +2382,7 @@
{
"alternate_pronunciations": [],
"alternate_syllabary": [],
- "cherokee": "De:jv̋ysdv dekdlǒ:hi.",
+ "cherokee": "De:jv̋sdv dekdlǒ:hi.",
"cherokee_audio": [
"online-exercises-audio/0971-Lessons71_to_84-000075485.mp3"
],
@@ -2536,7 +2536,7 @@
{
"alternate_pronunciations": [],
"alternate_syllabary": [],
- "cherokee": "Agwa̋:sa wigě:dôli dida:náɂnv̋ɂi.",
+ "cherokee": "Agwa̋:sa widagě:dô:li dida:náɂnv̋ɂi.",
"cherokee_audio": [
"online-exercises-audio/1016-Lessons71_to_84-000209275.mp3"
],
@@ -2769,7 +2769,7 @@
"source/en/moses_is_spoken_of_in_mark_chapt_Kendra_fc360968edff7440f4c2bfd2359adbf97220f33d.mp3",
"source/en/moses_is_spoken_of_in_mark_chapt_Joey_fc360968edff7440f4c2bfd2359adbf97220f33d.mp3"
],
- "syllabary": "ᎹᏚ ᏐᏁᎵᏁ ᎠᏯᏙᏢ ᎠᏥᏃᎮᎭ ᎼᏏ"
+ "syllabary": "ᎹᎦ ᏐᏁᎵᏁ ᎠᏯᏙᏢ ᎠᏥᏃᎮᎭ ᎼᏏ"
},
{
"alternate_pronunciations": [],
diff --git a/src/data/collections/ssw-credits.json b/src/data/collections/ssw-credits.json
new file mode 100644
index 00000000..f86dbb00
--- /dev/null
+++ b/src/data/collections/ssw-credits.json
@@ -0,0 +1,21 @@
+{
+ "credits": [{ "role": "Author", "name": "Cherokee Nation" }],
+ "description": "This collection contains terms lifted from a textbook aimed at teaching first-language speakers to read and write the syllabary.",
+ "externalResources": [
+ {
+ "name": "Online self-study course from Cherokee Nation",
+ "href": "https://learn.cherokee.org/course/index.php?categoryid=3",
+ "notes": "You must make a free account to view the book here"
+ },
+ {
+ "name": "Cherokee Nation Language Department Homepage",
+ "href": "https://language.cherokee.org/",
+ "notes": "Has many great free resources, including posters and other print outs."
+ },
+ {
+ "name": "Book download with audio from Cherokee Nation",
+ "href": "https://language.cherokee.org/media/a5adru4x/see-say-write.zip",
+ "notes": "Download your copy of the See Say Write textbook. Try printing out exercise pages to practice writing!"
+ }
+ ]
+}
diff --git a/src/data/collections/ssw.json b/src/data/collections/ssw.json
index 032583c1..5a4368d9 100644
--- a/src/data/collections/ssw.json
+++ b/src/data/collections/ssw.json
@@ -174,7 +174,7 @@
"Hila̋ ǐ:ga̋ juwě:ji dejadu:lí?",
"Hila̋ iyú:de:tiyv̋:da hiɂa achű:ja.",
"Ju:ni:là:wisdi̋ idè:na.",
- "Gwiligi dù:do:ɂa achű:ja."
+ "Gwiligi dù:do:ɂa hiɂa achű:ja."
]
},
{
@@ -218,7 +218,7 @@
"Gaɂlo:hni kaɂlv̋ anvnsgó unitelvládi.",
"Íhě:dô:lv:ɂi.",
"Ajvgalí:yé:di ga:nǐ:dô:ha.",
- "De:jv̋ysdv dekdlǒ:hi.",
+ "De:jv̋sdv dekdlǒ:hi.",
"Hi:yo:lì:gis kilő dludlu jù:do:ɂǐ:da?",
"Ada ti:sdlû:hya.",
"Ha:dlv dajì:hluni hiɂa gǎ:da?",
@@ -229,7 +229,7 @@
"Hlawò:tu:hi̋ nigalsdi:sgó yù:ga:hnana.",
"Hla yà:gwanhta.",
"Nű:la! À:gwv:nv́:ga.",
- "Agwa̋:sa wigě:dôli dida:náɂnv̋ɂi.",
+ "Agwa̋:sa widagě:dô:li dida:náɂnv̋ɂi.",
"À:gwv̌:ké:wa dejadó:ɂv̌:ɂi.",
"Hihv̌:na.",
"Dijalagi digigo:lǐ:yê:di disgwé:hyo:hv̀:ga.",
diff --git a/src/data/migrations/2022-08-25.ts b/src/data/migrations/2022-08-25.ts
index e7185275..9baceb2f 100644
--- a/src/data/migrations/2022-08-25.ts
+++ b/src/data/migrations/2022-08-25.ts
@@ -1,4 +1,6 @@
-export const migration = {
+import { addMigration } from "./all";
+
+addMigration({
vhla: "v́ːhla",
"ayo!": "ayő!",
"v̀:hla ő:sda yi̋gi": "v́ːhla ő:sda yi̋gi.",
@@ -54,4 +56,4 @@ export const migration = {
nagwu: "v̀:sginagwu",
wahayaju: "wahayagwuju",
naj: "naju",
-};
+});
diff --git a/src/data/migrations/2022-12-16.ts b/src/data/migrations/2022-12-16.ts
new file mode 100644
index 00000000..a280d5b0
--- /dev/null
+++ b/src/data/migrations/2022-12-16.ts
@@ -0,0 +1,6 @@
+import { addMigration } from "./all";
+
+addMigration({
+ "sóhněla:du": "sóhně:la:du",
+ "Agwa̋:sa wigě:dôli dida:náɂnv̋ɂi.": "Agwa̋:sa wigě:dô:li dida:náɂnv̋ɂi.",
+});
diff --git a/src/data/migrations/2023-03-06-phonetics-fixes.ts b/src/data/migrations/2023-03-06-phonetics-fixes.ts
new file mode 100644
index 00000000..793b122d
--- /dev/null
+++ b/src/data/migrations/2023-03-06-phonetics-fixes.ts
@@ -0,0 +1,7 @@
+import { addMigration } from "./all";
+
+addMigration({
+ "gwiligi dù:do:ɂa achű:ja": "Gwiligi dù:do:ɂa hiɂa achű:ja.",
+ "de:jv̋ysdv dekdlǒ:hi": "De:jv̋sdv dekdlǒ:hi.",
+ "agwa̋:sa wigě:dô:li dida:náɂnv̋ɂi": "Agwa̋:sa widagě:dô:li dida:náɂnv̋ɂi.",
+});
diff --git a/src/data/migrations/2023-03-31-corrections.ts b/src/data/migrations/2023-03-31-corrections.ts
new file mode 100644
index 00000000..5e8b6a25
--- /dev/null
+++ b/src/data/migrations/2023-03-31-corrections.ts
@@ -0,0 +1,7 @@
+import { addMigration } from "./all";
+
+addMigration({
+ "ka²³wi² a¹¹gwa²du²²li³": "ka²³wi² a¹¹gwa²du²²li³ha²",
+ "do² u⁴⁴sdi, ha²su²²yv¹¹ʔa²": "do² u⁴⁴sdi, ha²su²²yv¹¹ga²",
+ "di²²sgi²di³²si, ha²ye²²la,sdi²": "ha²ye²²la,sdi² di²²sgi²di³²si,",
+});
diff --git a/src/data/migrations/all.ts b/src/data/migrations/all.ts
new file mode 100644
index 00000000..9b5c1756
--- /dev/null
+++ b/src/data/migrations/all.ts
@@ -0,0 +1,12 @@
+import { cherokeeToKey } from "../cards";
+
+// null marks that term should be _deleted_
+export const migrations: Record[] = [];
+
+export function addMigration(migration: Record) {
+ migrations.push(
+ Object.fromEntries(
+ Object.entries(migration).map(([k, v]) => [cherokeeToKey(k), v])
+ )
+ );
+}
diff --git a/src/data/migrations/index.ts b/src/data/migrations/index.ts
index 5feef3b0..70f541d3 100644
--- a/src/data/migrations/index.ts
+++ b/src/data/migrations/index.ts
@@ -1,10 +1,31 @@
import { cherokeeToKey } from "../cards";
+// import new migrations here
+import "./2022-08-25";
+import "./2022-12-16";
+import "./2023-03-06-phonetics-fixes";
+import "./2023-03-31-corrections";
+import { migrations } from "./all";
+
+function applyMigration(
+ term: string | null,
+ migration: Record
+): string | null {
+ // dropped terms stay dropped
+ if (!term) return null;
-export function applyMigration(
- term: string,
- migration: Record
-): string {
const key = cherokeeToKey(term);
if (!(key in migration)) return term;
- else return cherokeeToKey(migration[cherokeeToKey(term)]);
+ else {
+ const migrated = migration[key];
+
+ if (migrated) return cherokeeToKey(migrated);
+ // if the term was dropped return null
+ else return null;
+ }
}
+
+export const migrateTerm = (term: string) =>
+ migrations.reduce(
+ (t, migration) => applyMigration(t, migration),
+ term
+ );
diff --git a/src/data/terms_by_lesson.json b/src/data/terms_by_lesson.json
deleted file mode 100644
index 418ac5de..00000000
--- a/src/data/terms_by_lesson.json
+++ /dev/null
@@ -1 +0,0 @@
-{"Chapters 1-4": ["o:síyo; Síyo", "v:ʔv.; v:ʔ.", "vhla.; hla; tla", "howa", "ayo!", "tò:hi̋", "tò:hi̋:ju?", "tò:hi̋:sgo?; tò:hi̋:s?", "v:ʔv. tò:hi̋:gwu.; v:ʔ. tò:hi̋:gwu.", "v:ʔv. ő:sda.; v:ʔ. ő:sda.", "v̀:hla ő:sda yi̋gi.; hla ő:sd yi̋gi.; hla ő:sd yi̋g.; hlahv ő:sd yi̋gi.", "v̀:hla tò:hi̋ yi̋gi.; hla tò:hi̋ yi̋gi.; hla tò:hi̋ yi̋g.; hlahv tò:hi̋ yi̋gi.", "wado", "v̀:sgigi?", "go:hű:sdi; go:hű:sd; gő:sdi; gő:sd", "donada:go:hv:ʔi", "dodada:go:hv:ʔi", "u:yő:ʔi ada; u:yő ada", "ni! a̋ʔda", "ayv:wi", "yó:ne:ga; yó:ne:g; yv́:wúne:ga; yv́:wúne:g", "nvhgi:ne̋:ʔi ayv:wi; nvhgi:ne̋ ayv:wi", "igv:yi̋:ʔi à:de:hlohgwà:sdi; igv:yi̋ à:de:hlohgwà:sdi", "taʔli dide:hlohgwà:sdi", "aya:do:hlv̋:ʔi; aya:do:hlv̋", "gawò:nǐ:hísdi; gawò:nǐ:hísd", "taʔli:ne̋:ʔi ahwi; taʔli:ne̋ ahwi", "joʔi:ne̋:ʔi nohji; joʔi:ne̋ nohj", "joʔi nǔ:na; jo nǔ:na", "Áhani ǐ:gá:da ama.; Áhni ǐ:gá:d ama.", "Áhani ǐ:gá:da á:ma.; Áhni ǐ:gá:d á:ma.", "u:ne̋:ga ada; u:ne̋:g ada", "tigo:lǐ:ya.", "tatv:dà:sda"], "Chapter 5": ["hisgi:ne̋:ʔi agě:hya; hisgi:ne̋ agě:hya", "gadō ű:sdi asgaya?; gadō ű:sd asgaya?; dō ű:sd asgaya?", "hila̋ ǐ:ga̋ nǔ:na?; hila̋ nǔ:na?", "hila̋ iyáni gi:hli?; hila̋ gi:hli?", "A:sésdi.", "Di:sésdi.", "sà:gwű nv̀:ya", "táʔli digo:hwe:li", "a:nitáʔli wě:sa; a:nitáʔl wě:sa", "a:nijoʔi ahawi; a:nijo ahwi", "nvhgi wahaya; nvhg wahya", "hisgi do:ya; hisg do:ya", "sǔ:dáli ji:sdu; sǔ:dál ji:sdu", "gahlgwǒ:gi ani:ge:hyű:ja; gahlgwǒ:g ani:ge:hyű:j", "sǔ:dáli:ne̋:ʔi achű:ja; sǔ:dáli:ne̋ achű:j", "gahlgwǒ:gi:ne̋:ʔi salǒ:li; gahlgwǒ:gi:ne̋ salǒ:l", "nitadv̀:ga", "to:hwe:lv̀:ga"], "Chapter 6": ["sadv́:di à:gowhtíha; sadv́:d à:gowhtí", "sǒ:gwíli à:nigowhtíha; sǒ:gwíl à:nigowhtí", "gi:hli dà:gowhtíha asgaya; gi:hli dà:gowhtí asgaya", "ani:sgaya yǒ:na dà:nigowhtíha; ani:sgaya yǒ:na dà:nigowhtí", "gi:hli ale wě:sa; gi:hli ale wě:s", "gadō jű:sdi nǔ:na?; gadō jű:sd nǔ:n?; dō jű:sd nǔ:n?", "gadō jű:sdi nv̀:ya?; gadō jű:sd nv̀:ya?; dō jű:sd nv̀:ya?", "gadō űnsdi ani:gě:hya?; gadō űnsdi ani:gě:hya?; dō űnsdi ani:gě:hya?", "gadō űnsdi ani:sgaya?; gadō űnsd ani:sgaya?; dō űnsd ani:sgaya?", "disdà:tv:dà:sda", "nidisdadv̀:ga"], "Chapter 7": ["gi:hli ji:gowhtíha; gi:hli ji:gowhtí", "go:hwe:li jigowhtíha; go:hwe:l jigowhtí", "wě:sa ga:ji:gowhtíha; wě:s ga:ji:gowhtí", "gu:le de:jigowhtíha; gu:le de:jigowhtí", "ahawi hi:gowhtíha; ahwi hi:gowhtí", "tǔ:ya higowhtíha; tǔ:ya higowhtí", "wahaya ga:hi:gowhtíha; wahya ga:hi:gowhtí", "di:sadv́:di de:higowhtíha; di:sadv́:d de:higowhtí", "áhani ale ù:hna̋; áhni ale ù:hna̋", "i̋:na ale naʔv", "disdo:hwe:lv̀:ga", "disdigo:lǐ:ya."], "Chapter 8": ["ayv; aya", "v̀:sgina; v̀:sgi; sgina; nà:sgi", "ha:dlv v̀:sgina; ha:dlv v̀:sgi; ha:dlv sgina; ha:dlv nà:sgi", "ha:dlv na hlgv̋:ʔi", "ha:dlv na dě:hlgv̋:ʔi", "agě:hya hiʔa à:gowhtíha; agě:hya hiʔa à:gowhtí; agě:hya hiʔi à:gowhtíha; agě:hya hiʔi à:gowhtí", "nihi", "nidijadv̀:ga", "dì:jo:hwe:lv̀:ga"], "Chapter 9": ["Gawó:ni:sgv́:ʔi à:ktahv́:ʔi; Gawó:ni:sgv́ à:ktahv́", "agě:hyahv?", "v̀:sginahv?; v̀:sgihv?; sginahv?; nà:sgihv?", "asgayana?", "hiʔana?; hiʔina?", "gi:hlisgo?; gi:hlis?", "nasgo?; nas?", "hiʔasgo?; hiʔisgo?; hiʔas?; hiʔis?", "ahawigwu.; ahwigwu.", "nagwu.", "hiʔagwu.; hiʔigwu.", "wahayaju?; wahyaju", "naj.", "hiʔaju.; hiʔiju.", "hla agě:hya yi̋:gi. asgaya.; hla agě:hya yi̋:g. asgai.", "hla gi:hli yi̋:gi. wě:sa.; hla gi:hli yi̋:g. wě:s.", "hlasgo ahawi yi̋:gi; hlas ahwi yi̋:g?", "hlaju wahaya yi̋:gi; hlaju wahya yi̋:g?", "dì:jigo:lǐ:ya.", "dì:jà:tv:dà:sda"], "Chapter 10 ᏍᎪᎯᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["mé:li, donada:go:hv:ʔi", "nihi ale sú:sano, dodada:go:hv:ʔi", "à:ni:ʔahawi; à:ni:ʔahwi", "à:ni:gi:hli", "à:ni:ji:sdu; à:ni:ji:sd", "à:ni:wahaya; à:ni:wahya", "à:ni:wě:sa; à:ni:wě:s", "já:ni, dv:gv:go:hi; já:n, dv:gv:go:hi", "já:ni, dv:sgi:go:hi; já:n, dv:sgi:go:hi", "lí:nida ale ma:gá:li, dv:sdv:go:hi; lí:nid ale ma:gá:l, dv:sdv:go:hi", "a:lí:sa:gwe:ti, dv:sgini:go:hi, énto:ni ale ayv.; a:lí:sa:gwe:t, dv:sgini:go:hi, énto:n ale ayv.", "lí:nida ale ma:gá:li, dv:sgini:go:hi; lí:nid ale ma:gá:l, dv:sgini:go:hi", "niga̋:da nihi, dv:ʔì:jv:go:hi; niga̋:d nihi, dv:ʔì:jv:go:hi", "niga̋:da ayv dv:ʔì:jv:go:hi; niga̋:d ayv dv:ʔì:jv:go:hi", "dv:ʔì:sgi:go:hi, niga̋:da ayv.; dv:ʔì:sgi:go:hi, niga̋:d ayv.", "niga̋:da nihi, dv:ʔì:sgi:go:hi; niga̋:d nihi, dv:ʔì:sgi:go:hi"], "Chapter 11 ᏌᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["gadō à:dv́:neha age:hyű:ja?; gadō à:dv́:ne age:hyű:j?; dō à:dv́:ne age:hyű:j?", "joʔga:ʔdu ani:ge:hyű:ja áhani.; joʔga:ʔd ani:ge:hyű:j áhni.", "gadō à:dv́:neha achű:ja?; gadō à:dv́:ne achű:j?; dō à:dv́:ne achű:j?", "gadō à:na:dv́:neha ani:chű:ja?; gadō à:na:dv́:ne ani:chű:j?; dō à:na:dv́:ne ani:chű:j?", "hlgv̋:ʔi jigowhtíha; hlgv̋ jigowhtí", "dě:hlgv̋:ʔi dà:nigowhtíha; dě:hlgv̋ dà:nigowhtí", "sa:ʔdu", "chaně:la; chaně:l; chuně:la; chuně:l", "sóhně:la; sóhně:l", "sgǒ:hi.", "taʔldu", "nò:t", "ka:nǐgîda", "niga:ʔdu", "sgiga:ʔdu", "da:la:du", "gahlgwǎ:du", "ně:la:du", "sóhněla:du", "táʔlsgǒ:hi", "joʔsgǒ:hi", "nvksgǒ:hi", "hiksgǒ:hi", "sǔ:dálsgǒ:hi", "gahlgwǎ:sgǒ:hi", "nélsgǒ:hi", "sóhnélsgǒ:hi", "sgǒ:hítsgwa"], "Chapter 12 ᏔᎳᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["hilâ:yv̋:ʔi?; hilâ:yv̋?", "gá:gō?", "gâ:gí?", "gadò?; dò?", "gadògí?; dògí?", "gadō u:sdi?; dō u:sd?", "gadò:hv̋", "énto:ni dà:gwadó:a; énto:n dà:gwadó", "lí:nida ale ma:gá:li dò:gi:nadó:a; lí:nid ale ma:gá:l dò:gi:nadó", "má:gaye:ti, mé:li, ale gwa:gwaʔa dò:gadó:a; má:gaye:t, mé:l, ale gwa:gwa dò:gadó", "a:lí:sa:gwe:ti dě:jádó:ʔa; a:lí:sa:gwe:t dě:jádó", "sú:sano ale tó:masi dě:sdádó:ʔa; sú:san ale tó:mas dě:sdádó", "dé:widi, chá:li, ale já:ni dè:jádó:ʔa; dé:wid, chá:li, ale já:n dè:jádó", "wahaya dù:dó:ʔa; wahya dù:dó", "salǒ:li, sǒ:gwíli, ale yǒ:na dù:nadó:ʔa; salǒ:l, sǒ:gwíl, ale yǒ:na dù:nadó", "salǒ:li, íhě:dô:lv:ʔi; salǒ:l, íhě:dô:lv:ʔi", "sǒ:gwíli ale yǒ:na, ísdě:dô:lv:ʔi; sǒ:gwíl ale yǒ:na, ísdě:dô:lv:ʔi", "salǒ:li, sǒ:gwíli, ale yǒ:na íʔì:jě:dô:lv:ʔi; salǒ:l, sǒ:gwíl, ale yǒ:na íʔì:jě:dô:lv:ʔi", "gili:si; gili:s", "yv:wi", "agili:si; agili:s", "ani:gili:si; ani:gili:s", "ajalagi; ajalag", "à:nő:sda ani:jalagi; à:nő:sd ani:jalag", "ayó:ne:ga; ayó:ne:g", "u:niyő:ʔi ani:yó:ne:ga; u:niyő ani:yó:ne:g", "ani:yv:wi", "ayv:wi:ya̋:ʔi; ayv:wi:ya̋", "ani:yv:wi:ya̋:ʔi; ani:yv:wi:ya̋"], "Chapter 13 ᏦᎦᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["si", "vhlási; hlási", "hla go:hű:sdi; hla go:hű:sd; hla gő:sdi; hla gő:sd", "uwőhldi go:hwe:li; uwőhld go:hwe:l", "juwőhldi digo:hwe:li; juwőhld digo:hwe:l", "dé:widi ji:sdu; dé:wid ji:sd", "na a:nijoʔi yǒ:na; na a:nijo yǒ:n", "sà:gwű u:yő ji:sdu.; sà:gwű u:yő ji:sd.", "síyo dé:widi; síyo dé:wid", "age:hyű:ja ale a:nijoʔi yǒ:na; age:hyű ale a:nijo yǒ:n", "áhni ji:sdu", "ji:sdu age:hyű:ja; ji:sd age:hyű:j", "gadō dě:jádó:ʔa; gadō dě:jádó; dō dě:jádó", "dé:widi ji:sdu dà:gwadó:a; dé:wid ji:sd dà:gwadó", "nihi ale ayv.", "nihi áhani ő:sda; nihi áhni ő:sd", "ha:dlv na a:nijoʔi yǒ:na?; ha:dlv na a:nijo yǒ:n?", "yǒ:nana a:nijoʔi?; yǒ:nana a:nijo?", "na u:yő:ʔi!; na u:yő!", "ù:hna̋sgo a:nijoʔi yǒ:na?; ù:hna̋s a:nijo yǒ:n?", "na a:nijoʔi yǒ:na ù:hna̋.; na a:nijo yǒ:n ù:hna̋.", "Si! na a:nijoʔi yǒ:na áhani; Si! na a:nijo yǒ:na áhni", "ù:hna̋ ga:ji:gowhtíha, a:nitáʔli wě:sa; ù:hna̋ ga:ji:gowhtí, táʔli wě:s", "wě:sasgo? vhla! v̀:sgi a:nitáʔli gi:hli; wě:sas? hla! v̀:sgina táʔli gi:hli; wě:sas? hla! sgina táʔli gi:hli; wě:sas? hla! nà:sgi táʔli gi:hli", "yǒ:nana", "vhla yǒ:na áhani yi̋:gi; hla yǒ:n áhni yi̋:g", "gadō u:sdi áhani?; gadō u:sd áhni?; dō u:sd áhni?", "ù:hna̋ jigowhtíha; ù:hna̋ jigowhtí", "go:hű:sdi ő:sda jigowhtíha; go:hű:sd ő:sd jigowhtí; gő:sdi ő:sda jigowhtíha; gő:sd ő:sd jigowhtí", "gadō u:sdi higowhtíha?; gadō u:sd higowhtí?; dō u:sdi higowhtí?", "howa. go:hű:sdi ő:sda; howa. go:hű:sd ő:sd; howa. gő:sdi ő:sda; howa. gő:sd ő:sd", "ő:sdagwu", "ni! áhani sǒ:gwíli!; ni! áhni sǒ:gwíl!", "ù:hna̋ sà:gwű sǒ:gwíli.; ù:hna̋ sà:gwű sǒ:gwíl.", "áhani sà:gwű sǒ:gwíli.; áhni sà:gwű sǒ:gwíl.", "hiʔasgo ő:sda sǒ:gwíli?; hiʔas ő:sd sǒ:gwíl?; hiʔis ő:sd sǒ:gwíl?", "ő:sda sǒ:gwíli; ő:sd sǒ:gwíl", "na u:yő:ʔi sǒ:gwíli.; na u:yő sǒ:gwíl.", "ù:hna̋, ő:sda sà:gwű jigowhtíha", "dé:widi! yv:wi áhani!; dé:wid! yv:wi áhni!", "na yǒ:na achű:ja já:ni dù:dó:ʔa; na yǒ:n achű:j já:ni dù:dó", "gá:gō? gadō dù:dó:ʔa?; gá:gō? gadō dù:dó?; gá:gō? dō dù:dó?", "dě:jádó:ʔasgo dé:widi ji:sdu?; dě:jádó:ʔas dé:wid ji:sd?; dě:jádós dé:wid ji:sd?", "nihi na u:yő:ʔi ji:sdu!; nihi na u:yő ji:sd!"], "Chapter 14 ᏂᎦᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["ìje̋:ʔi tǔ:ya; ìje̋ tǔ:ya", "ijé:ʔiyű:sdisgo yǒ:na?; ijé:ʔiyű:sdis yǒ:n?", "ù:sgǒ:lv̋:ʔi; ù:sgǒ:lv̋", "uwǒ:díge̋ʔi nǔ:nagwu; uwǒ:díge̋ nǔ:nagwu", "gǐ:gáge̋:ʔi gu:le; gǐ:gáge̋ gu:le; gǐ:ge̋:ʔi gu:le; gǐ:ge̋ gu:le", "gv̌:hnáge̋ʔi sadv́:di; gv̌:hnáge̋ sadv́:d; gv̌:níge̋:ʔi sadv́:di; gv̌:níge̋ sadv́:d", "sakǒ:níge̋:ʔi sadv́:di; sakǒ:níge̋ sadv́:d", "daha:lige̋:ʔi nǔ:na; daha:lige̋ nǔ:na", "dalǒ:níge̋:ʔi nv̀:ya; dalǒ:níge̋ nv̀:ya", "agǐ:gáge̋:ʔi agě:hya; agǐ:gáge̋ agě:hya; agǐ:ge̋:ʔi agě:hya; agǐ:ge̋ agě:hya", "agv̌:hnáge̋:ʔi gi:hli; agv̌:hnáge̋ gi:hli; agv̌:níge̋:ʔi gi:hli; agv̌:níge̋ gi:hli", "asakǒ:níge̋:ʔi wě:sa; asakǒ:níge̋ wě:s", "adaha:lige̋:ʔi ahawi; adaha:lige̋ ahwi", "adalǒ:níge̋:ʔi ahawi; adalǒ:níge̋ ahwi", "gǐ:gáge̋:ʔi ù:sgǒ:lv̋:ʔi gu:le; gǐ:gáge̋:ʔi ù:sgǒ:lv̋ gu:le", "dalǒ:níge̋:ʔi ù:sgǒ:lv̋:ʔi tǔ:ya; dalǒ:níge̋ ù:sgǒ:lv̋ tǔ:ya"], "Chapter 15 ᏍᎩᎦᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["ani:je̋ tǔ:ya", "ani:jé:ʔiyű:sdi do:ya", "ani:gǐ:gáge̋ ji:sdu", "ani:daha:lige̋ wahaya", "ani:gv̌:hnáge̋ wahya", "ani:sakǒ:níge̋ salǒ:li", "ani:dalǒ:níge̋ sǒ:gwíli", "u:ni:ne̋:ga yǒ:na", "ju:nǒ:díge̋ gi:hli", "dije̋ nǔ:na", "dijé:ʔiyű:sdi di:sadv́:di", "ju:ne̋:ga dě:hlgv̋:ʔi", "juwǒ:díge̋:ʔi digo:hwe:li", "digǐ:gáge̋ go:hű:sdi; digǐ:gáge̋ gő:sdi", "digv̌:hnáge̋ nv̀:ya", "disakǒ:níge̋ gu:le", "didaha:lige̋:ʔi nǔ:na", "didalǒ:níge̋:ʔi nv̀:ya"], "Chapter 17 ᎦᎵᏆᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["gu:le ǔ:ha", "tǔ:ya ù:nǐ:ha", "sadv́:di à:giha", "nǔ:na jaha", "gi:hli ù:wa:káha; gi:hli ù:wa:ká", "wě:sa ù:ni:káha; wě:s ù:ni:ká", "ahawi à:gikáha; ahwi à:giká", "do:ya jakáha; do:ya jaká"], "Chapter 18 ᏁᎳᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["age:hyű:ja agigowhtíha", "achű:ja hla yagigowhtíha", "salǒ:li jagowhtíha", "hla yǒ:na yijagowhtíha", "hla gu:le yǔ:ha", "hla sadv́:di yù:nǐ:ha", "hla nǔ:na yà:giha", "hla nv̀:ya yijaha", "hla gi:hli yù:wa:káha", "hla wě:sa yù:ni:káha", "hla do:ya yà:gikáha", "hla ji:sdu yijakáha"], "Chapter 19 ᏐᏁᎳᏚᏏᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["wahaya gv:gigowhtíha; wahya gv:gigowhtí", "vhla ani:ge:hyű:ja yigv:gigowhtíha; hla ani:ge:hyű:j yigv:gigowhtí", "ani:chű:ja ge:jagowhtíha; ani:chű:j ge:jagowhtí", "vhla salǒ:li yige:jagowhtíha; hla salǒ:l yige:jagowhtí", "di:sadv́:di dǔ:ha; di:sadv́:d dǔ:ha", "nǔ:na dù:nǐ:ha; nǔ:n dù:nǐ:ha", "nv̀:ya dà:giha", "gu:le de:jaha", "ahawi dù:wa:káha; ahwi dù:wa:ká", "do:ya dù:ni:káha; do:ya dù:ni:ká", "ji:sdu dà:gikáha; ji:sd dà:giká", "wahaya de:jakáha; wahya de:jaká", "vhla di:sadv́:di yidǔ:ha; hla di:sadv́:d yidǔ:ha", "vhla nǔ:na yidù:nǐ:ha", "vhla nv̀:ya yida:giha", "vhla gu:le yidi:jaha", "vhla gi:hli yidù:wa:káha; hla gi:hli yidù:wa:ká", "vhla wě:sa yidù:ni:káha; hla wě:s yidù:ni:ká", "vhla ahawi yida:gikáha; hla ahwi yida:giká", "vhla do:ya yidi:jakáha; hla do:ya yidi:jaká"], "Chapter 20 ᏔᎵᏍᎪᎯᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["i̋:gi", "i̋:ga", "gè:só:ʔi; gè:só", "gè:sv̌:ʔi; gè:sv̌", "gè:sv̌:gi; gè:sv̌:g", "gè:sé:ʔi; gè:sé", "gè:hv̌:ʔi; gè:hv̌", "gè:hv̌:gi; gè:hv̌", "gè:hé:ʔi; gè:hé", "gè:sé:sdi; gè:sé:sd"], "Chapter 21 ᏔᎵᏍᎪ ᏌᏊᎯᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["gwa:gwaʔa ji:ya:du:líha; gwa:gwa ji:ya:du:lí", "nǔ:na à:gwadu:líha; nǔ:n à:gwadu:lí", "na agě:hya à:gwadu:líha; na agě:hya à:gwadu:lí", "gi:hli ga:ji:ya:du:líha; gi:hli ga:ji:ya:du:lí", "diga:sgilo dà:gwadu:líha; diga:sgil dà:gwadu:lí", "a:lí:sa:gwe:ti ale énto:ni gv̀:gwadu:líha; a:lí:sa:gwe:t ale énto:n gv̀:gwadu:lí", "ma:gá:li lí:nida ù:du:líha; ma:gá:l lí:nid ù:du:lí", "má:gaye:ti wě:sa dù:du:líha; má:gaye:ti wě:s dù:du:lí", "hi:ya:du:líhasgo?; hi:ya:du:líhas?; hi:ya:du:lísgo?; hi:ya:du:lís?", "naju sadv́:di jadu:líha?; naju sadv́:d jadu:lí?", "mé:li jadu:líha; mé:li jadu:lí", "ahawisgo ga:hi:ya:du:líha?; ahawis ga:hi:ya:du:lí?", "tǔ:yasgo de:jadu:líha; tǔ:yas de:jadu:lí", "na ani:sgaya ge:jadu:líha.; na ani:sgaya ge:jadu:lí.", "go:hű:sdi ù:na:du:líha; go:hű:sd ù:na:du:lí; gő:sdi ù:na:du:lí", "go:hű:sdi dù:na:du:líha; go:hű:sd dù:na:du:lí; gő:sdi dù:na:du:lí", "a:sé:hno, ji:sdu ga:ji:ya:du:líha.; a:sé:hno, ji:sd ga:ji:ya:du:lí.; a:sé:hnv, ji:sd ga:ji:ya:du:lí.", "ga:sgilo jigowhtíha; ga:sgilo jigowhtí"], "Chapter 22 ᏔᎵᏍᎪ ᏔᎵᏁᎢ ᎠᏕᎶᏆᏍᏗ": ["ka, vhla nǔ:na yà:gwadu:líha; ka! hla nǔ:na yà:gwadu:lí!", "nű:la, sú:sano jadu:líha; nű:la, sú:sano jadu:lí", "a:nitáʔli iyáni ani:ge:hyű:ja; a:nitáʔl iyáni ani:ge:hyű:j", "joʔi ǐ:ga̋ dě:hlgv̋:ʔi; jo ǐ:ga̋ dě:hlgv̋", "nǔ:na ga:sgilv̋:ʔi; nǔ:na ga:sgilv̋", "gadō u:sdi ulsǔ:hwida na gi:hli?; gadō u:sd ulsǔ:hwid na gi:hli?; dō u:sd ulsǔ:hwid na gi:hli?", "gadō u:sdi u:nalsǔ:hwida na ji:sdu?; gadō u:sd u:nalsǔ:hwid na ji:sd?; dō u:sd u:nalsǔ:hwid na ji:sd?", "gadō u:sdi julsǔ:hwida na nǔ:na?; gadō u:sd julsǔ:hwid na nǔ:n?; dō u:sd julsǔ:hwid na nǔ:n?", "na do:ya ulsǔ:hwida uwǒ:díge̋:ʔi; na do:ya ulsǔ:hwid uwǒ:díge̋", "na ji:sdu u:nalsǔ:hwida u:ni:ne̋:ga; na ji:sd u:nalsǔ:hwid u:ni:ne̋:g", "na go:hű:sdi julsǔ:hwida didaha:lige̋:ʔi; na gő:sd julsǔ:hwid didaha:lige̋", "u:niyő wě:sa", "na u:yő gè:sv̌:gi", "na u:niyő ani:yv:wi gè:sv̌:gi", "ha:dlv à:nő:sda yv:wi", "à:nő:sda yv:wi hla áhni yi̋:gi", "ù:hnana?", "ani:yv:wi ù:hna yigè:sé:sdi; ani:yv:wi ù:hna yigè:sé:sd", "asgaya hla na yà:gowhtí ayv áhni", "howa! ayv áhni!", "u:niyő ani:yv:wi áhni yi̋:g", "ha:dlv yv:wi?", "hla áhni yi̋:g", "hiʔa u:yő", "hla nà:sgi yiji:gowhtí; hla v̀:sgi yiji:gowhtí; hla sgina yiji:gowhtí; hla v̀:sgina yiji:gowhtí", "hla ő:sd yi̋:g", "nihiju ù:hna", "hla yiji:gowhtíha", "ha:dlv nihi?", "ù:hna u:yő gè:sv̌:g", "agiyő à:gwadu:lí", "agiyő à:gwadu:líha. jayős jadu:lí?", "ajalag wě:s gè:sé.", "agili:s wě:s gè:sé", "ő:sd wě:s gè:sé. u:yő wě:s gè:sé.", "u:niyő ani:yv:wi áhni", "nű:la. nű:la.", "na nvhg gi:hli ga:hi:ya:du:lí", "yi̋:gi", "gadō u:sdi hiʔa?", "na wě:s à:nő:sd.", "nà:sgi ga:ji:ya:du:lí; v̀:sgi ga:ji:ya:du:lí; sgina ga:ji:ya:du:lí; v̀:sgina ga:ji:ya:du:lí"]}
\ No newline at end of file
diff --git a/src/data/vocabSets.ts b/src/data/vocabSets.ts
index 5a6d4658..f36e64c2 100644
--- a/src/data/vocabSets.ts
+++ b/src/data/vocabSets.ts
@@ -1,8 +1,15 @@
import { cherokeeToKey } from "./cards";
import CLL1 from "./collections/cll1.json";
+import CLL1Credits from "./collections/cll1-credits.json";
+import JWLivingPhrases from "./collections/jw-living-phrases.json";
+import JWLivingPhrasesCredits from "./collections/jw-living-phrases-credits.json";
import SSW from "./collections/ssw.json";
+import SSWCredits from "./collections/ssw-credits.json";
-function cleanSet(set: VocabSet, collectionId: string): VocabSet {
+function cleanSet(
+ set: Omit,
+ collectionId: string
+): VocabSet {
return {
...set,
collection: collectionId,
@@ -10,11 +17,27 @@ function cleanSet(set: VocabSet, collectionId: string): VocabSet {
};
}
-export function cleanCollection({ id, title, sets }: Collection): Collection {
+export function cleanCollection({
+ id,
+ title,
+ sets,
+}: Omit): Omit {
+ return {
+ id,
+ title,
+ sets: sets.map((set) => cleanSet(set, id)),
+ };
+}
+
+function addCredits(
+ { id, title, sets }: Omit,
+ credits: CollectionCredits
+) {
return {
id,
title,
sets: sets.map((set) => cleanSet(set, id)),
+ credits,
};
}
@@ -38,26 +61,52 @@ export function applyMigrationsToCollection(
};
}
+interface DiskCollection {
+ id: string;
+ title: string;
+ sets: Omit[];
+}
export interface Collection {
id: string;
title: string;
sets: VocabSet[];
+ credits: CollectionCredits;
+}
+
+export interface ExternalResource {
+ name: string;
+ href: string;
+ notes?: string;
+}
+
+export interface CollectionCredits {
+ credits: { role: string; name: string }[];
+ description: string;
+ externalResources: ExternalResource[];
}
export interface VocabSet {
id: string;
title: string;
- collection?: string;
+ collection: string;
terms: string[];
}
export const CHEROKEE_LANGUAGE_LESSONS_COLLLECTION = CLL1.id;
export const SEE_SAY_WRITE_COLLECTION = SSW.id;
+export const JW_LIVING_PHRASES = JWLivingPhrases.id;
// additional sets can be added here
export const collections: Record = {
- [SEE_SAY_WRITE_COLLECTION]: cleanCollection(SSW),
- [CHEROKEE_LANGUAGE_LESSONS_COLLLECTION]: cleanCollection(CLL1),
+ [SEE_SAY_WRITE_COLLECTION]: addCredits(cleanCollection(SSW), SSWCredits),
+ [JW_LIVING_PHRASES]: addCredits(
+ cleanCollection(JWLivingPhrases),
+ JWLivingPhrasesCredits
+ ),
+ [CHEROKEE_LANGUAGE_LESSONS_COLLLECTION]: addCredits(
+ cleanCollection(CLL1),
+ CLL1Credits
+ ),
};
export const vocabSets: Record = Object.fromEntries(
diff --git a/src/firebase/AuthProvider.tsx b/src/firebase/AuthProvider.tsx
new file mode 100644
index 00000000..74090728
--- /dev/null
+++ b/src/firebase/AuthProvider.tsx
@@ -0,0 +1,76 @@
+import {
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { analytics, auth } from ".";
+import { onAuthStateChanged, signInAnonymously, User } from "firebase/auth";
+import { LoadingPage } from "../components/Loader";
+import { setUserId } from "firebase/analytics";
+
+export interface AuthContext {
+ user: User;
+}
+
+const authContext = createContext(null);
+
+function useFirebaseUser() {
+ /**
+ * `User`: signed in user
+ * `null`: user is not signed in
+ * `undefined`: we haven't loaded yet
+ */
+ const [user, setUser] = useState(undefined);
+ useEffect(() => {
+ return onAuthStateChanged(auth, (newUser) => {
+ setUser(newUser);
+ if (newUser) {
+ setUserId(analytics, newUser.uid);
+ }
+ });
+ });
+ return user;
+}
+
+export function AuthProvider({ children }: { children?: ReactNode }) {
+ const user = useFirebaseUser();
+ useEffect(() => {
+ // null means user is not signed in, but we have loaded the auth state
+ if (user === null) {
+ signInAnonymously(auth).then((uc) => {});
+ }
+ }, [user]);
+ if (user)
+ return (
+ {children}
+ );
+ else {
+ return (
+
+ Connecting...
+
+ );
+ }
+}
+
+export function useAuth(): AuthContext {
+ const context = useContext(authContext);
+ if (context === null) throw new Error("Must be used under an AuthProvider");
+ return context;
+}
+
+export function MockAuthProvider({
+ userId,
+ children,
+}: {
+ userId: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/firebase/hooks.ts b/src/firebase/hooks.ts
new file mode 100644
index 00000000..1f15e6e8
--- /dev/null
+++ b/src/firebase/hooks.ts
@@ -0,0 +1,90 @@
+import { logEvent } from "firebase/analytics";
+import { User } from "firebase/auth";
+import { onValue, set } from "firebase/database";
+import { useEffect, useMemo, useState } from "react";
+import { analytics } from ".";
+import {
+ allLessonMetadataPath,
+ lessonMetadataPath,
+ lessonReviewedTermsPath,
+ localStorageStateBackupPath,
+ TypedRef,
+ userConfigPath,
+ userLeitnerBoxesPath,
+} from "./paths";
+
+export type LoadingState = { ready: false };
+export type DataState = { ready: true; data: T | null };
+
+export type FirebaseState = LoadingState | DataState;
+
+/**
+ * Like `useState` or `useLocalStorage` but its in Firebase.
+ */
+export function useFirebase(
+ typedRef: TypedRef
+): [FirebaseState, (newState: T) => Promise] {
+ const [state, setInternalState] = useState>({
+ ready: false,
+ });
+
+ useEffect(() => {
+ onValue(typedRef.ref, (snapshot) => {
+ const data = snapshot.val() ?? null;
+ setInternalState({ ready: true, data });
+ });
+ }, [typedRef]);
+
+ const setFirebaseState = useMemo(
+ () => (newState: T) => set(typedRef.ref, newState),
+ [typedRef]
+ );
+
+ return [state, setFirebaseState];
+}
+
+/**
+ * Makes sure this route is tracked in Google Analytics.
+ */
+export function useAnalyticsPageName(pageName: string): void {
+ useEffect(() => {
+ logEvent(analytics, "page_view", {
+ page_title: pageName,
+ });
+ }, [pageName]);
+}
+
+export function useFirebaseLeitnerBoxes(user: User) {
+ const ref = useMemo(() => userLeitnerBoxesPath(user), [user]);
+ return useFirebase(ref);
+}
+
+export function useFirebaseUserConfig(user: User) {
+ const ref = useMemo(() => userConfigPath(user), [user]);
+ return useFirebase(ref);
+}
+
+export function useFirebaseLocalStorageStateBackup(user: User) {
+ const ref = useMemo(() => localStorageStateBackupPath(user), [user]);
+ return useFirebase(ref);
+}
+
+export function useFirebaseLessonMetadata(user: User, lessonId: string) {
+ const ref = useMemo(
+ () => lessonMetadataPath(user, lessonId),
+ [user, lessonId]
+ );
+ return useFirebase(ref);
+}
+export function useFirebaseAllLessonMetadata(user: User) {
+ const ref = useMemo(() => allLessonMetadataPath(user), [user]);
+ return useFirebase(ref);
+}
+
+export function useFirebaseReviewedTerms(user: User, lessonId: string) {
+ const ref = useMemo(
+ () => lessonReviewedTermsPath(user, lessonId),
+ [user, lessonId]
+ );
+ return useFirebase(ref);
+}
diff --git a/src/firebase/index.ts b/src/firebase/index.ts
new file mode 100644
index 00000000..810e073c
--- /dev/null
+++ b/src/firebase/index.ts
@@ -0,0 +1,28 @@
+// Import the functions you need from the SDKs you need
+
+import { initializeApp } from "firebase/app";
+import { getAnalytics } from "firebase/analytics";
+import { getDatabase } from "firebase/database";
+import { getAuth } from "firebase/auth";
+
+// TODO: Add SDKs for Firebase products that you want to use
+// https://firebase.google.com/docs/web/setup#available-libraries
+// Your web app's Firebase configuration
+// For Firebase JS SDK v7.20.0 and later, measurementId is optional
+
+const firebaseConfig = {
+ apiKey: "AIzaSyCAkc6q14mWKYd_eQYTdHf1WjhVkNzf3kQ",
+ authDomain: "cherokee-language-exerci-5bac5.firebaseapp.com",
+ projectId: "cherokee-language-exerci-5bac5",
+ storageBucket: "cherokee-language-exerci-5bac5.appspot.com",
+ messagingSenderId: "913050570673",
+ appId: "1:913050570673:web:a8aa84e49682b427f77790",
+ measurementId: "G-CZT375VHC7",
+};
+
+// Initialize Firebase
+
+export const app = initializeApp(firebaseConfig);
+export const analytics = getAnalytics(app);
+export const auth = getAuth(app);
+export const db = getDatabase(app);
diff --git a/src/firebase/migration.ts b/src/firebase/migration.ts
new file mode 100644
index 00000000..62ebf38e
--- /dev/null
+++ b/src/firebase/migration.ts
@@ -0,0 +1,28 @@
+import { User } from "firebase/auth";
+import { ReviewResult } from "../state/reducers/leitnerBoxes";
+import { lessonKey } from "../state/reducers/lessons";
+import { LegacyUserState } from "../state/useUserState";
+import { setTyped, lessonMetadataPath, lessonReviewedTermsPath } from "./paths";
+
+export async function uploadAllLessonDataFromLocalStorage(
+ user: User
+): Promise {
+ const localStorageUserState: LegacyUserState = JSON.parse(
+ localStorage.getItem("user-state") || "{}"
+ );
+
+ await Promise.all(
+ Object.values(localStorageUserState.lessons ?? {}).flatMap((lesson) => {
+ const reviewedTerms: Record = JSON.parse(
+ localStorage.getItem(lessonKey(lesson.id) + `/reviewed-terms`) ?? "{}"
+ );
+ return [
+ setTyped(lessonMetadataPath(user, lesson.id), lesson),
+ setTyped(lessonReviewedTermsPath(user, lesson.id), {
+ reviewedTerms,
+ lessonId: lesson.id,
+ }),
+ ];
+ })
+ );
+}
diff --git a/src/firebase/paths.ts b/src/firebase/paths.ts
new file mode 100644
index 00000000..8ec6d58f
--- /dev/null
+++ b/src/firebase/paths.ts
@@ -0,0 +1,70 @@
+import { User } from "firebase/auth";
+import { DatabaseReference, ref, set } from "firebase/database";
+import { db } from ".";
+import { FirebaseReviewedTerms } from "../spaced-repetition/useReviewSession";
+import { LeitnerBoxState } from "../state/reducers/leitnerBoxes";
+import { Lesson } from "../state/reducers/lessons";
+import { LegacyUserState, UserConfig } from "../state/useUserState";
+import { IssueReport } from "./types";
+
+export type TypedRef = {
+ ref: DatabaseReference;
+ // make sure we remember the type
+ type?: T;
+};
+
+export function setTyped(ref: TypedRef, value: T): Promise {
+ return set(ref.ref, value);
+}
+
+export function userLeitnerBoxesPath(user: User): TypedRef {
+ return {
+ ref: ref(db, `users/${user.uid}/leitnerBoxes`),
+ };
+}
+
+export function userConfigPath(user: User): TypedRef {
+ return {
+ ref: ref(db, `users/${user.uid}/config`),
+ };
+}
+
+export function localStorageStateBackupPath(
+ user: User
+): TypedRef {
+ return {
+ ref: ref(db, `users/${user.uid}/backupLocalStorageState`),
+ };
+}
+
+export function lessonMetadataPath(
+ user: User,
+ lessonId: string
+): TypedRef {
+ return {
+ ref: ref(db, `users/${user.uid}/lessonMeta/${lessonId}`),
+ };
+}
+
+export function allLessonMetadataPath(
+ user: User
+): TypedRef> {
+ return {
+ ref: ref(db, `users/${user.uid}/lessonMeta/`),
+ };
+}
+
+export function lessonReviewedTermsPath(
+ user: User,
+ lessonId: string
+): TypedRef {
+ return {
+ ref: ref(db, `users/${user.uid}/lessonReviewedTerms/${lessonId}/`),
+ };
+}
+
+export function issueReportPath(issueId: string): TypedRef {
+ return {
+ ref: ref(db, `issueReports/${issueId}`),
+ };
+}
diff --git a/src/firebase/types.ts b/src/firebase/types.ts
new file mode 100644
index 00000000..b33b804c
--- /dev/null
+++ b/src/firebase/types.ts
@@ -0,0 +1,10 @@
+import { Card } from "../data/cards";
+
+export interface IssueReport {
+ card: Card;
+ problematicAudio?: string;
+ createdAt?: number;
+ userId: string;
+ userEmail: string;
+ description: string;
+}
diff --git a/src/index.css b/src/index.css
index 76d871aa..26464a2c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -17,3 +17,7 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+
+button {
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
index dae8b52d..f7b17225 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,22 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
-import App from "./App";
+import { HashRouter } from "react-router-dom";
import "./index.css";
import reportWebVitals from "./reportWebVitals";
-import { BrowseSets } from "./views/sets/BrowseSets";
-import { MySets } from "./views/sets/MySets";
-import { ViewSet } from "./views/sets/ViewSet";
// import { Overview } from "./Overview";
-import { UserStateProvider } from "./state/UserStateProvider";
-import { Dashboard } from "./views/dashboard/Dashboard";
-import { LessonArchive } from "./views/lessons/LessonArchive";
-import { ViewLesson } from "./views/lessons/ViewLesson";
-import { PracticeLesson } from "./views/practice/PracticeLesson";
-import { NewLesson } from "./views/lessons/NewLesson";
-import { ViewCollection } from "./views/collections/ViewCollection";
-import { MyTerms } from "./views/terms/MyTerms";
-import { Settings } from "./views/settings/Settings";
+import { UserStateProvider } from "./providers/UserStateProvider";
+import { AuthProvider } from "./firebase/AuthProvider";
+import { AllRoutes } from "./routing/AllRoutes";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
@@ -25,39 +15,11 @@ const root = ReactDOM.createRoot(
root.render(
-
-
- }>
- } />
- }
- />
-
-
- } />
- } />
-
- } />
-
- } />
-
- } />
- } />
- {/* "reviewOnly" is an optional parameter so we make two routes https://stackoverflow.com/questions/70005601/alternate-way-for-optional-parameters-in-v6 */}
-
- } />
- } />
-
-
-
- } />
- }>
-
- } />
-
-
-
+
+
+
+
+
);
diff --git a/src/providers/LessonProvider.tsx b/src/providers/LessonProvider.tsx
new file mode 100644
index 00000000..cc3fc9df
--- /dev/null
+++ b/src/providers/LessonProvider.tsx
@@ -0,0 +1,107 @@
+import { useReviewedTerms } from "../spaced-repetition/useReviewSession";
+import { Lesson, reduceLesson } from "../state/reducers/lessons";
+import { ReviewResult } from "../state/reducers/leitnerBoxes";
+import { useUserStateContext } from "./UserStateProvider";
+import { analytics } from "../firebase";
+import { logEvent } from "firebase/analytics";
+import { FirebaseState, useFirebaseLessonMetadata } from "../firebase/hooks";
+import { useAuth } from "../firebase/AuthProvider";
+import { LessonsAction } from "../state/actions";
+import React, { ReactNode, useContext, useEffect } from "react";
+import { Loader, SmallLoader } from "../components/Loader";
+
+interface UseLessonData {
+ lesson: Lesson;
+ reviewedTerms: Record;
+ reviewTerm: (term: string, correct: boolean) => void;
+ concludeLesson: () => void;
+ startLesson: () => void;
+}
+
+const lessonContext = React.createContext(null);
+
+/**
+ * Internal and messy use lesson that can have load times
+ */
+function useLessonInternal(lessonId: string): FirebaseState {
+ const { dispatch: dispatchGlobal } = useUserStateContext();
+ const { user } = useAuth();
+ const reviewedTerms = useReviewedTerms(lessonId);
+
+ const [firebaseLesson, setLesson] = useFirebaseLessonMetadata(user, lessonId);
+
+ if (!firebaseLesson.ready) return firebaseLesson;
+
+ // we didn't find anything
+ if (firebaseLesson.data === null) return { data: null, ready: true };
+
+ const { data: lesson } = firebaseLesson;
+
+ function dispatch(action: LessonsAction) {
+ dispatchGlobal(action);
+ const newLesson = reduceLesson(lesson, action);
+ return setLesson(newLesson);
+ }
+
+ return {
+ ready: true,
+ data: {
+ lesson,
+ ...reviewedTerms,
+ concludeLesson: () => {
+ logEvent(analytics, "lesson_finished", {
+ lessonId: lesson.id,
+ });
+ dispatch({
+ type: "CONCLUDE_LESSON",
+ lesson,
+ reviewedTerms: reviewedTerms.reviewedTerms,
+ });
+ },
+ startLesson: () => {
+ logEvent(analytics, "lesson_started", {
+ lessonId,
+ });
+ dispatch({
+ type: "START_LESSON",
+ lessonId,
+ });
+ },
+ },
+ };
+}
+
+export function useLesson(): UseLessonData {
+ const result = useContext(lessonContext);
+ if (result === null)
+ throw new Error("Use lesson must be used inside a LessonProvider");
+ return result;
+}
+
+export function LessonProvider({
+ lessonId,
+ onLessonDoesNotExist,
+ children,
+}: {
+ lessonId: string;
+ onLessonDoesNotExist: () => void;
+ children?: ReactNode;
+}) {
+ const result = useLessonInternal(lessonId);
+
+ useEffect(() => {
+ if (result.ready && result.data === null) {
+ onLessonDoesNotExist();
+ }
+ }, [result]);
+
+ if (!result.ready)
+ return Fetching lesson data...} />;
+ if (result.data === null) return Lesson not found ;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/providers/UserStateProvider.tsx b/src/providers/UserStateProvider.tsx
new file mode 100644
index 00000000..e06863f5
--- /dev/null
+++ b/src/providers/UserStateProvider.tsx
@@ -0,0 +1,179 @@
+import React, {
+ ReactElement,
+ ReactNode,
+ useContext,
+ useMemo,
+ useEffect,
+} from "react";
+import { useLocalStorage } from "react-use";
+import { GroupRegistrationModal } from "../components/GroupRegistrationModal";
+import { LoadingPage } from "../components/Loader";
+import { useAuth } from "../firebase/AuthProvider";
+import {
+ useFirebaseLeitnerBoxes,
+ useFirebaseLocalStorageStateBackup,
+ useFirebaseUserConfig,
+} from "../firebase/hooks";
+import { uploadAllLessonDataFromLocalStorage } from "../firebase/migration";
+import { UserStateAction } from "../state/actions";
+import { LeitnerBoxState } from "../state/reducers/leitnerBoxes";
+import {
+ UserState,
+ UserInteractors,
+ LegacyUserState,
+ UserConfig,
+ useUserState,
+ convertLegacyState,
+} from "../state/useUserState";
+
+export interface UserStateContext extends UserState, UserInteractors {
+ dispatch: React.Dispatch;
+}
+
+const userStateContext = React.createContext(null);
+
+export interface UserStatePersistenceContext {
+ // browser stored state
+ localStorageUserState?: LegacyUserState;
+ flagLocalStateAndUploadLessons: () => void;
+ // -- Firebase slices --
+ // config
+ config: UserConfig | null;
+ setConfig: (newConfig: UserConfig) => void;
+ // leitner boxes
+ leitnerBoxes: LeitnerBoxState | null;
+ setLeitnerBoxes: (newLeitnerBoxes: LeitnerBoxState) => void;
+}
+export const userStatePersistenceContext =
+ React.createContext(null);
+
+function UserStatePersistenceProvider({
+ children,
+}: {
+ children: ReactElement;
+}) {
+ const { user } = useAuth();
+ const [localStorageUserState, setLocalStorageUserState] =
+ useLocalStorage("user-state", undefined, {
+ raw: false,
+ serializer: JSON.stringify,
+ deserializer: (s) => JSON.parse(s.normalize("NFD")), // ensure everything is NFD!
+ });
+
+ const [leitnerBoxes, setLeitnerBoxes] = useFirebaseLeitnerBoxes(user);
+ const [config, setConfig] = useFirebaseUserConfig(user);
+ const [localStorageStateBackup, setLocalStorageStateBackup] =
+ useFirebaseLocalStorageStateBackup(user);
+
+ useEffect(() => {
+ // if there is no backup of the user's local storage state, upload whatever is in local storage
+ if (
+ localStorageStateBackup.ready &&
+ localStorageStateBackup.data === null
+ ) {
+ if (localStorageUserState !== undefined)
+ setLocalStorageStateBackup(localStorageUserState);
+ }
+ }, [localStorageStateBackup]);
+
+ if (config.ready && leitnerBoxes.ready) {
+ return (
+
+ {children}
+
+ );
+ } else {
+ return (
+
+ Loading your data...
+
+ );
+ }
+}
+
+function WrappedUserStateProvider({
+ children,
+}: {
+ children: ReactNode;
+}): ReactElement {
+ const persistenceContext = useContext(userStatePersistenceContext);
+ if (persistenceContext === null) throw new Error("explode");
+ const {
+ localStorageUserState,
+ flagLocalStateAndUploadLessons,
+ config,
+ setConfig,
+ leitnerBoxes,
+ setLeitnerBoxes,
+ } = persistenceContext;
+
+ const storedUserState = useMemo(() => {
+ if (config === null || leitnerBoxes === null) {
+ if (localStorageUserState === undefined) return undefined;
+ flagLocalStateAndUploadLessons();
+ return convertLegacyState(localStorageUserState);
+ } else {
+ return { config, leitnerBoxes };
+ }
+ }, [localStorageUserState, config, leitnerBoxes]);
+
+ const { state, interactors, dispatch } = useUserState({
+ storedUserState,
+ initializationProps: {
+ leitnerBoxes: {
+ numBoxes: 6,
+ },
+ },
+ });
+
+ // sync segments of state independently
+ useEffect(() => {
+ setConfig(state.config);
+ }, [state.config]);
+ useEffect(() => {
+ setLeitnerBoxes(state.leitnerBoxes);
+ }, [state.leitnerBoxes]);
+
+ return (
+
+ {children}
+ {(state.config.userEmail === null || state.config.groupId === null) && (
+
+ )}
+
+ );
+}
+
+export function UserStateProvider({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useUserStateContext(): UserStateContext {
+ const value = useContext(userStateContext);
+ if (value === null) throw new Error("Must be used under a UserStateProvider");
+ return value;
+}
diff --git a/src/routing/AllRoutes.tsx b/src/routing/AllRoutes.tsx
new file mode 100644
index 00000000..688f010b
--- /dev/null
+++ b/src/routing/AllRoutes.tsx
@@ -0,0 +1,50 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+import { App } from "../App";
+import { Dashboard } from "../views/dashboard/Dashboard";
+import { LessonArchive } from "../views/lessons/LessonArchive";
+import { NewLesson } from "../views/lessons/NewLesson";
+import { ViewLesson } from "../views/lessons/ViewLesson";
+import { PracticeLesson } from "../views/practice/PracticeLesson";
+import { MyTerms } from "../views/terms/MyTerms";
+import { BrowseCollections } from "../views/vocabulary/BrowseCollections";
+import { MySets } from "../views/vocabulary/MySets";
+import { ViewCollection } from "../views/vocabulary/ViewCollection";
+import { ViewSet } from "../views/vocabulary/ViewSet";
+import { Settings } from "../views/settings/Settings";
+
+/**
+ * All the routes for the app.
+ *
+ * Note: if you update this file, you should also updates the paths.ts file in
+ * this folder.
+ */
+export function AllRoutes() {
+ return (
+
+ }>
+ } />
+
+ } />
+ } />
+ } />
+
+ }>
+ } />
+
+ } />
+ } />
+ {/* "reviewOnly" is an optional parameter so we make two routes https://stackoverflow.com/questions/70005601/alternate-way-for-optional-parameters-in-v6 */}
+
+ } />
+ } />
+
+
+
+ } />
+ }>
+
+ } />
+
+
+ );
+}
diff --git a/src/routing/paths.ts b/src/routing/paths.ts
new file mode 100644
index 00000000..06e29871
--- /dev/null
+++ b/src/routing/paths.ts
@@ -0,0 +1,22 @@
+// A list of constants to be used for links in the app
+// This _must_ be updated as routes are added / change.
+
+export const DashboardPath = "/";
+export const VocabularyPath = "/vocabulary";
+export const BrowseCollectionsPath = VocabularyPath;
+export const ViewSetPath = (setId: string) => `${VocabularyPath}/set/${setId}`;
+export const ViewCollectionPath = (collectionId: string) =>
+ `${VocabularyPath}/collection/${collectionId}`;
+export const MySetsPath = "/my-sets";
+export const MyTermsPath = "/terms";
+export const LessonsPath = "/lessons";
+export const ViewLessonPath = (lessonId: string) =>
+ `${LessonsPath}/${lessonId}`;
+export const NewLessonPath = (
+ numChallenges: string | number,
+ reviewOnly?: boolean
+) => `${LessonsPath}/new/${numChallenges}/${reviewOnly ?? ""}`;
+export const PracticePath = `/practice`;
+export const PracticeLessonPath = (lessonId: string) =>
+ `${PracticePath}/${lessonId}`;
+export const SettingsPath = `/settings`;
diff --git a/src/spaced-repetition/useReviewSession.tsx b/src/spaced-repetition/useReviewSession.tsx
index 25a66881..4d899ed6 100644
--- a/src/spaced-repetition/useReviewSession.tsx
+++ b/src/spaced-repetition/useReviewSession.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
-import { useLocalStorage } from "react-use";
+import { useFirebase, useFirebaseReviewedTerms } from "../firebase/hooks";
import { TermCardWithStats, TermStats } from "./types";
import { LeitnerBoxState, ReviewResult } from "../state/reducers/leitnerBoxes";
import { lessonKey } from "../state/reducers/lessons";
@@ -9,12 +9,19 @@ import {
showsPerSessionForBox,
usePimsleurTimings,
} from "./usePimsleurTimings";
+import { useAuth } from "../firebase/AuthProvider";
+import { useLocalStorage } from "react-use";
export interface UseLeitnerReviewSessionReturn {
current: TermCardWithStats;
next: (result: ReviewResult) => void;
}
+export interface FirebaseReviewedTerms {
+ reviewedTerms: Record;
+ lessonId: string;
+}
+
function updateReviewResult(
current: ReviewResult | undefined,
correct: boolean
@@ -41,22 +48,31 @@ function updateReviewResult(
}
}
-export function useReviewedTerms(lessonId: string) {
- const [storedReviewedTerms, setReviewedTerms] = useLocalStorage<
- Record
- >(`${lessonKey(lessonId)}/reviewed-terms`, undefined, {
- raw: false,
- serializer: JSON.stringify,
- deserializer: JSON.parse,
- });
- const reviewedTerms = storedReviewedTerms ?? {};
+export function useReviewedTerms(lessonId: string): {
+ reviewedTerms: Record;
+ reviewTerm: (term: string, correct: boolean) => void;
+} {
+ const { user } = useAuth();
+ const [firebaseReviewedTerms, setReviewedTerms] = useFirebaseReviewedTerms(
+ user,
+ lessonId
+ );
+ const reviewedTerms =
+ (firebaseReviewedTerms.ready &&
+ firebaseReviewedTerms.data?.reviewedTerms) ||
+ {};
return {
reviewedTerms,
reviewTerm(term: string, correct: boolean) {
+ // there is sort of a race condition here where we could commit a result
+ // with our dummy state `{}` and overwrite the database.
setReviewedTerms({
- ...reviewedTerms,
- [term]: updateReviewResult(reviewedTerms[term], correct),
+ lessonId,
+ reviewedTerms: {
+ ...reviewedTerms,
+ [term]: updateReviewResult(reviewedTerms[term], correct),
+ },
});
},
};
diff --git a/src/state/UserStateProvider.tsx b/src/state/UserStateProvider.tsx
deleted file mode 100644
index 5b1064b3..00000000
--- a/src/state/UserStateProvider.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import React, {
- ReactElement,
- ReactNode,
- useContext,
- useEffect,
- useMemo,
-} from "react";
-import { useLocalStorage } from "react-use";
-import {
- LessonsInteractors,
- LessonsState,
- reduceLessonsState,
- useLessonsInteractors,
-} from "./reducers/lessons";
-import {
- LeitnerBoxesInteractors,
- LeitnerBoxState,
- reduceLeitnerBoxState,
- useLeitnerBoxesInteractors,
-} from "./reducers/leitnerBoxes";
-import { useReducerWithImperative } from "../utils/useReducerWithImperative";
-import {
- reduceUserSetsState,
- UserSetsInteractors,
- UserSetsState,
- useUserSetsInteractors,
-} from "./reducers/userSets";
-import { UserStateAction } from "./actions";
-import { LessonCreationError } from "./reducers/lessons/createNewLesson";
-import { GroupId, GROUPS, isGroupId, reduceGroupId } from "./reducers/groupId";
-import { GroupRegistrationModal } from "../components/GroupRegistrationModal";
-import { PhoneticsPreference } from "./reducers/phoneticsPreference";
-
-export interface UserStateProps {
- leitnerBoxes: {
- numBoxes: number;
- };
-}
-
-export interface UserState {
- /** Terms the user is learning and ther progress */
- leitnerBoxes: LeitnerBoxState;
- /** Lessons that have been created for the user */
- lessons: LessonsState;
- /** Latest error describing why a lesson could not be created */
- lessonCreationError: LessonCreationError | undefined;
- /** Sets the user is learning */
- sets: UserSetsState;
- /** The collection from which new sets should be pulled when the user is ready for new terms */
- upstreamCollection: string | undefined;
- /** Group registration */
- groupId: GroupId | undefined;
- /** Preference for how phonetics are shown */
- phoneticsPreference: PhoneticsPreference | undefined;
-}
-
-interface MiscInteractors {
- setUpstreamCollection: (collectionId: string) => void;
- registerGroup: (groupId: string) => void;
- setPhoneticsPreference: (newPreference: PhoneticsPreference) => void;
- loadState: (state: UserState) => void;
-}
-
-export type UserInteractors = UserSetsInteractors &
- LessonsInteractors &
- LeitnerBoxesInteractors &
- MiscInteractors;
-
-function reduceUpstreamCollection(
- { upstreamCollection }: UserState,
- action: UserStateAction
-): string | undefined {
- if (action.type === "SET_UPSTREAM_COLLECTION") return action.newCollectionId;
- if (action.type === "REGISTER_GROUP_AND_APPLY_DEFAULTS")
- // if no upstream collection, set to group default
- return upstreamCollection ?? GROUPS[action.groupId].defaultCollectionId;
- else return upstreamCollection;
-}
-
-function reducePhoneticsPreference(
- state: UserState,
- action: UserStateAction
-): PhoneticsPreference | undefined {
- if (action.type === "SET_PHONETICS_PREFERENCE") return action.newPreference;
- if (action.type === "REGISTER_GROUP_AND_APPLY_DEFAULTS")
- // if no preference, set to group default when a user registers
- return (
- state.phoneticsPreference ?? GROUPS[action.groupId].phoneticsPreference
- );
- else return state.phoneticsPreference;
-}
-
-function reduceLessonCreationError(
- { lessonCreationError }: UserState,
- action: UserStateAction
-): LessonCreationError | undefined {
- if (action.type === "LESSON_CREATE_ERROR") return action.error;
- else return lessonCreationError;
-}
-
-function reduceUserState(state: UserState, action: UserStateAction): UserState {
- // bail on individual resovlers if loading state
- if (action.type === "LOAD_STATE") return action.state;
-
- return {
- leitnerBoxes: reduceLeitnerBoxState(state, action),
- lessons: reduceLessonsState(state, action),
- sets: reduceUserSetsState(state, action),
- upstreamCollection: reduceUpstreamCollection(state, action),
- lessonCreationError: reduceLessonCreationError(state, action),
- groupId: reduceGroupId(state, action),
- phoneticsPreference: reducePhoneticsPreference(state, action),
- };
-}
-
-function initializeUserState({
- storedUserState,
- initializationProps,
-}: {
- storedUserState?: UserState;
- initializationProps: UserStateProps;
-}): UserState {
- if (storedUserState) return storedUserState;
- else
- return {
- lessons: {},
- sets: {},
- leitnerBoxes: {
- numBoxes: initializationProps.leitnerBoxes.numBoxes,
- terms: {},
- },
- upstreamCollection: undefined,
- lessonCreationError: undefined,
- groupId: undefined,
- phoneticsPreference: undefined,
- };
-}
-
-export function useUserState(props: {
- storedUserState?: UserState;
- initializationProps: UserStateProps;
-}) {
- const [state, dispatch, dispatchImperativeBlock] = useReducerWithImperative(
- reduceUserState,
- props,
- initializeUserState
- );
-
- const userSetsInteractors = useUserSetsInteractors(
- state,
- dispatch,
- dispatchImperativeBlock
- );
-
- const lessonsInteractors = useLessonsInteractors(
- state,
- dispatch,
- dispatchImperativeBlock
- );
-
- const leitnerBoxesInteractors = useLeitnerBoxesInteractors(
- state,
- dispatch,
- dispatchImperativeBlock
- );
-
- const miscInteractors: MiscInteractors = useMemo(
- () => ({
- setUpstreamCollection(collectionId: string | undefined) {
- dispatch({
- type: "SET_UPSTREAM_COLLECTION",
- newCollectionId: collectionId,
- });
- },
- registerGroup(groupId: string) {
- if (isGroupId(groupId)) {
- dispatch({
- type: "REGISTER_GROUP_AND_APPLY_DEFAULTS",
- groupId,
- });
- }
- },
- loadState(state: UserState) {
- dispatch({
- type: "LOAD_STATE",
- state,
- });
- dispatch({ type: "HANDLE_SET_CHANGES" });
- },
- setPhoneticsPreference(newPreference) {
- dispatch({
- type: "SET_PHONETICS_PREFERENCE",
- newPreference,
- });
- },
- }),
- []
- );
-
- useEffect(() => {
- // handle resizes in number of boxes if we ever deploy them
- if (
- state.leitnerBoxes.numBoxes !==
- props.initializationProps.leitnerBoxes.numBoxes
- )
- leitnerBoxesInteractors.resize(
- props.initializationProps.leitnerBoxes.numBoxes
- );
- if (state.groupId) {
- dispatch({
- groupId: state.groupId,
- type: "REGISTER_GROUP_AND_APPLY_DEFAULTS",
- });
- }
- dispatch({ type: "HANDLE_SET_CHANGES" });
- }, []);
-
- return {
- state,
- interactors: {
- ...userSetsInteractors,
- ...lessonsInteractors,
- ...leitnerBoxesInteractors,
- ...miscInteractors,
- },
- };
-}
-
-// context provider
-
-export interface UserStateContext extends UserState, UserInteractors {}
-
-const userStateContext = React.createContext(null);
-
-export function UserStateProvider({
- children,
-}: {
- children: ReactNode;
-}): ReactElement {
- const [storedUserState, setStoredUserState] = useLocalStorage(
- "user-state",
- undefined,
- {
- raw: false,
- serializer: JSON.stringify,
- deserializer: JSON.parse,
- }
- );
-
- const { state, interactors } = useUserState({
- storedUserState,
- initializationProps: {
- leitnerBoxes: {
- numBoxes: 6,
- },
- },
- });
-
- useEffect(() => {
- setStoredUserState(state);
- }, [state]);
-
- return (
-
- {children}
- {state.groupId === undefined && (
-
- )}
-
- );
-}
-
-export function useUserStateContext(): UserStateContext {
- const value = useContext(userStateContext);
- if (value === null) throw new Error("Must be used under a UserStateProvider");
- return value;
-}
diff --git a/src/state/actions.ts b/src/state/actions.ts
index 89606e58..4592cf12 100644
--- a/src/state/actions.ts
+++ b/src/state/actions.ts
@@ -3,7 +3,7 @@ import { ReviewResult } from "./reducers/leitnerBoxes";
import { Lesson } from "./reducers/lessons";
import { LessonCreationError } from "./reducers/lessons/createNewLesson";
import { PhoneticsPreference } from "./reducers/phoneticsPreference";
-import { UserState } from "./UserStateProvider";
+import { LegacyUserState, UserState } from "./useUserState";
export type ResizeLeitnerBoxesAction = {
type: "RESIZE_LEITNER_BOXES";
@@ -24,7 +24,7 @@ export type SetAction = AddSetAction | RemoveSetAction;
export type SetUpstreamCollectionAction = {
type: "SET_UPSTREAM_COLLECTION";
- newCollectionId: string | undefined;
+ newCollectionId: string | null;
};
export type RegisterWithGroupAction = {
@@ -34,12 +34,7 @@ export type RegisterWithGroupAction = {
export type LoadStateAction = {
type: "LOAD_STATE";
- state: UserState;
-};
-
-export type AddLessonAction = {
- type: "ADD_LESSON";
- lesson: Lesson;
+ state: LegacyUserState;
};
export type StartLessonAction = {
@@ -49,7 +44,7 @@ export type StartLessonAction = {
export type ConcludeLessonAction = {
type: "CONCLUDE_LESSON";
- lessonId: string;
+ lesson: Lesson;
reviewedTerms: Record;
};
@@ -59,7 +54,6 @@ export type FlagLessonCreationError = {
};
export type LessonsAction =
- | AddLessonAction
| ConcludeLessonAction
| StartLessonAction
| FlagLessonCreationError;
@@ -73,6 +67,10 @@ export type SetPhoneticsPreferenceAction = {
type: "SET_PHONETICS_PREFERENCE";
newPreference: PhoneticsPreference;
};
+export type SetUserEmailAction = {
+ type: "SET_USER_EMAIL";
+ newUserEmail: string;
+};
export type UserStateAction =
| SetUpstreamCollectionAction
@@ -82,4 +80,5 @@ export type UserStateAction =
| SetAction
| LessonsAction
| HandleSetChangesAction
- | SetPhoneticsPreferenceAction;
+ | SetPhoneticsPreferenceAction
+ | SetUserEmailAction;
diff --git a/src/state/reducers/groupId.ts b/src/state/reducers/groupId.ts
index 3f3c21bc..1fc11718 100644
--- a/src/state/reducers/groupId.ts
+++ b/src/state/reducers/groupId.ts
@@ -1,6 +1,6 @@
import { SEE_SAY_WRITE_COLLECTION } from "../../data/vocabSets";
import { UserStateAction } from "../actions";
-import { UserState } from "../UserStateProvider";
+import { UserState } from "../useUserState";
import { PhoneticsPreference } from "./phoneticsPreference";
export interface Group {
@@ -18,7 +18,7 @@ const __groups = {
phoneticsPreference: PhoneticsPreference.Simple,
},
[OPEN_BETA_ID]: {
- name: "Open beta (not affiliated)",
+ name: "Other / not affiliated",
},
} as const;
@@ -35,8 +35,8 @@ export function isGroupId(id: string): id is GroupId {
export function reduceGroupId(
state: UserState,
action: UserStateAction
-): GroupId | undefined {
+): GroupId | null {
if (action.type === "REGISTER_GROUP_AND_APPLY_DEFAULTS")
return action.groupId;
- else return state.groupId;
+ else return state.config.groupId;
}
diff --git a/src/state/reducers/leitnerBoxes.ts b/src/state/reducers/leitnerBoxes.ts
index ef1d5cb8..09d7c2b9 100644
--- a/src/state/reducers/leitnerBoxes.ts
+++ b/src/state/reducers/leitnerBoxes.ts
@@ -1,13 +1,12 @@
import { DateTime, DurationLike } from "luxon";
-import { Dispatch, Reducer, useMemo, useReducer } from "react";
+import { Dispatch, useMemo } from "react";
import { getToday } from "../../utils/dateUtils";
import { TermStats } from "../../spaced-repetition/types";
import { ImperativeBlock } from "../../utils/useReducerWithImperative";
-import { UserState } from "../UserStateProvider";
+import { UserState } from "../useUserState";
import { vocabSets } from "../../data/vocabSets";
import { UserStateAction } from "../actions";
-import { migration } from "../../data/migrations/2022-08-25";
-import { applyMigration } from "../../data/migrations";
+import { migrateTerm } from "../../data/migrations";
interface NewUseLeitnerBoxesProps {
type: "NEW";
@@ -120,6 +119,8 @@ export function reduceLeitnerBoxState(
numBoxes,
};
case "CONCLUDE_LESSON":
+ if (action.lesson.type === "PRACTICE") return { terms, numBoxes };
+ // do not move terms for practice lessons
return {
terms: Object.entries(action.reviewedTerms).reduce(
(newTerms, [term, result]) => ({
@@ -134,7 +135,7 @@ export function reduceLeitnerBoxState(
const setToRemove = vocabSets[action.setToRemove];
// we need to figure out which terms are used ONLY by the set we are removing
// to do this, we remove any terms which appear in another set
- const termsUniqueToSet = Object.values(globalState.sets)
+ const termsUniqueToSet = Object.values(globalState.config.sets)
// get all other sets
.filter((stats) => stats.setId !== setToRemove.id)
// get terms for those sets
@@ -171,14 +172,16 @@ export function reduceLeitnerBoxState(
numBoxes: action.newNumBoxes,
};
case "HANDLE_SET_CHANGES":
- const termsFromAllSets = Object.keys(globalState.sets).flatMap(
+ const termsFromAllSets = Object.keys(globalState.config.sets).flatMap(
(setId) => vocabSets[setId].terms
);
const termsWithMigrations = Object.fromEntries(
- Object.entries(terms).map(([term, stats]) => {
- const newTerm = applyMigration(term, migration);
- return [newTerm, { ...stats, key: newTerm }];
- })
+ Object.entries(terms)
+ .map(([term, stats]) => {
+ const newTerm = migrateTerm(term);
+ return [newTerm, { ...stats, key: newTerm }];
+ })
+ .filter(([newTerm, _]) => newTerm !== null)
);
return {
terms: addNewTermsIfMissing(
@@ -222,3 +225,17 @@ export function useLeitnerBoxesInteractors(
[dispatch]
);
}
+
+/**
+ * Create an empty leitner box state for a practice lesson (ie. progress not counted).
+ */
+export function practiceLessonLeitnerBoxes(
+ terms: string[],
+ numBoxes: number
+): LeitnerBoxState {
+ const today = getToday();
+ return {
+ terms: Object.fromEntries(terms.map((t) => [t, newTermStats(t, today)])),
+ numBoxes,
+ };
+}
diff --git a/src/state/reducers/lessons/createNewLesson.ts b/src/state/reducers/lessons/createNewLesson.ts
index 8b469634..cbf97d7e 100644
--- a/src/state/reducers/lessons/createNewLesson.ts
+++ b/src/state/reducers/lessons/createNewLesson.ts
@@ -3,9 +3,19 @@ import { VocabSet, collections } from "../../../data/vocabSets";
import { TermStats } from "../../../spaced-repetition/types";
import { showsPerSessionForBox } from "../../../spaced-repetition/usePimsleurTimings";
import { DAY, getToday } from "../../../utils/dateUtils";
-import { Act, StateWithThen } from "../../../utils/useReducerWithImperative";
-import { UserStateAction } from "../../actions";
-import { UserState } from "../../UserStateProvider";
+import { UserState } from "../../useUserState";
+
+export type Result =
+ | { type: "SUCCESS"; result: T }
+ | { type: "ERROR"; error: E };
+
+export type CreateLessonResult = Result<
+ {
+ setsToAdd: string[]; // new sets that have terms used in the lesson
+ lesson: Lesson;
+ },
+ LessonCreationErrorType
+>;
export enum LessonCreationErrorType {
"NOT_ENOUGH_TERMS_FOR_REVIEW_LESSON",
@@ -63,66 +73,75 @@ export function scanWhile(
export function pullNewSets(
state: UserState,
numNewTermsNeeded: number
-): [VocabSet[], number] {
- if (state.upstreamCollection === undefined)
+): [VocabSet[], string[], number] {
+ if (state.config.upstreamCollection === null)
throw new Error(
"No upstream collection. This should have been checked before calling."
);
- const collection = collections[state.upstreamCollection];
+ const collection = collections[state.config.upstreamCollection];
const remainingSetsInCollection = collection.sets.filter(
- (set) => !(set.id in state.sets)
+ (set) => !(set.id in state.config.sets)
);
- const [setsToAdd, termsFound] = scanWhile(
+ const [setsToAdd, { termsFound, count }] = scanWhile<
+ {
+ termsFound: string[];
+ count: number;
+ },
+ VocabSet
+ >(
remainingSetsInCollection,
- (termsFound, set) => termsFound + set.terms.length,
- (termsFound) => termsFound <= numNewTermsNeeded,
- 0
+ ({ termsFound, count }, set) => ({
+ count: count + set.terms.length,
+ termsFound: [...termsFound, ...set.terms],
+ }),
+ ({ count }) => count <= numNewTermsNeeded,
+ { termsFound: [], count: 0 }
);
- return [setsToAdd, termsFound];
+ return [setsToAdd, termsFound, count];
}
-function fetchNewTermsIfNeeded(
- desiredId: string,
+function findNewTermsIfNeeded(
numNewTermsToInclude: number,
- state: UserState,
- act: Act
-) {
+ state: UserState
+): Result<
+ { setsToAdd: VocabSet[]; termsFound: string[] },
+ LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON
+> {
const [_, potentialNewTerms] = splitNewTerms(state);
// we have enough terms
- if (potentialNewTerms.length >= numNewTermsToInclude) return act();
+ if (potentialNewTerms.length >= numNewTermsToInclude)
+ return {
+ type: "SUCCESS",
+ result: { setsToAdd: [], termsFound: [] },
+ };
// we don't have enough terms AND there's nowhere to get more
- if (state.upstreamCollection === undefined)
- return act({
- type: "LESSON_CREATE_ERROR",
- error: {
- lessonId: desiredId,
- type: LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON,
- },
- });
+ if (state.config.upstreamCollection === undefined)
+ return {
+ type: "ERROR",
+ error: LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON,
+ };
const numTermsToFind = numNewTermsToInclude - potentialNewTerms.length;
- const [setsToAdd, termsFound] = pullNewSets(state, numTermsToFind);
-
- if (termsFound < numTermsToFind)
- return act({
- type: "LESSON_CREATE_ERROR",
- error: {
- lessonId: desiredId,
- type: LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON,
- },
- });
-
- return act(
- ...setsToAdd.map((set) => ({
- type: "ADD_SET" as const,
- setToAdd: set.id,
- }))
+ const [setsToAdd, termsFound, numTermsFound] = pullNewSets(
+ state,
+ numTermsToFind
);
+
+ if (numTermsFound < numTermsToFind)
+ return {
+ type: "ERROR",
+ error: LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON,
+ };
+
+ return {
+ type: "SUCCESS",
+ result: { setsToAdd, termsFound },
+ };
}
/**
@@ -144,26 +163,17 @@ function splitNewTerms(state: UserState) {
}
/**
- * Creates a lesson with approximately the numChallenges requested.
- *
- * Acts as a single dispatch against the global user state reducer.
- *
- * See `dispatchImperativeBlock`.
- * @param desiredNumChallenges
- * @param state
- * @param act
- * @returns
+ * Create a lesson, possibly using new terms that will need to be added for the user.
*/
-export function createLessonTransaction(
+export function createLessonAndFindSetsToAdd(
desiredId: string,
desiredNumChallenges: number,
reviewOnly: boolean,
state: UserState,
- act: Act,
suggestedNewTermsToInclude?: number
-): StateWithThen {
- // if the lesson was already created, don't make it again
- if (desiredId in state.lessons) return act();
+): CreateLessonResult {
+ // // if the lesson was already created, don't make it again
+ // if (desiredId in state.lessons) return act();
// 10 terms for 15 minute lesson
// TODO: finetune this
@@ -176,86 +186,81 @@ export function createLessonTransaction(
? 0.5 * desiredNumChallenges // review only lessons can be shorter (so your last reivew lesson can be completed more often)
: 0.85 * desiredNumChallenges; // lessons with new terms should always be about full length
- return fetchNewTermsIfNeeded(
- desiredId,
- numNewTermsToInclude,
- state,
- act
- ).then((state, act) => {
- // if something has gone wrong, bail
- if (state.lessonCreationError?.lessonId === desiredId) return act();
-
- // split terms into review terms and new terms
- const [potentialReviewTerms, potentialNewTerms] = splitNewTerms(state);
-
- console.log({
- potentialReviewTerms: potentialReviewTerms.length,
- potentialNewTerms: potentialNewTerms.length,
- });
-
- // new terms are in box 0, by definition
- const numNewTermChallenges =
- numNewTermsToInclude * showsPerSessionForBox(0);
- const numReviewTermChallenges = desiredNumChallenges - numNewTermChallenges;
-
- // select review terms until we max number of challenges
- const [reviewTerms, reviewChallengesFound] = scanWhile(
- potentialReviewTerms,
- (count, term) => count + showsPerSessionForBox(term.box),
- (count) => count <= numReviewTermChallenges,
- 0
- );
+ const newTermsResult = findNewTermsIfNeeded(numNewTermsToInclude, state);
- // if there aren't enough terms...
- if (reviewChallengesFound + numNewTermChallenges < minChallenges) {
- if (reviewOnly) {
- console.log(
- "Not enough review terms for a lesson! But a review only lesson was requested!"
- );
- return act({
- type: "LESSON_CREATE_ERROR",
- error: {
- lessonId: desiredId,
- type: LessonCreationErrorType.NOT_ENOUGH_TERMS_FOR_REVIEW_LESSON,
- },
- });
- } else {
- // try to fill lesson with new terms
- return createLessonTransaction(
- desiredId,
- desiredNumChallenges,
- reviewOnly,
- state,
- act,
- Math.floor(
- // remaining challenges / challenges per new term
- (desiredNumChallenges - reviewChallengesFound) /
- showsPerSessionForBox(0)
- )
- );
- }
- }
+ if (newTermsResult.type === "ERROR") return newTermsResult;
+ const {
+ result: { setsToAdd, termsFound: newTermsFromSetsToAdd },
+ } = newTermsResult;
- // if we have enough terms...
- const newTerms = potentialNewTerms.slice(0, numNewTermsToInclude);
-
- const realNumChallenges =
- reviewChallengesFound + newTerms.length * showsPerSessionForBox(0);
-
- const lesson: Lesson = {
- id: desiredId,
- terms: [...newTerms, ...reviewTerms].map((t) => t.key),
- startedAt: null,
- completedAt: null,
- createdAt: Date.now(),
- createdFor: getToday(),
- numChallenges: realNumChallenges,
- type: "DAILY",
- };
+ // split terms into review terms and new terms
+ const [potentialReviewTerms, newTermsAlreadyAdded] = splitNewTerms(state);
+
+ const potentialNewTerms = [
+ ...newTermsAlreadyAdded.map((t) => t.key),
+ ...newTermsFromSetsToAdd,
+ ];
+
+ // new terms are in box 0, by definition
+ const numNewTermChallenges = numNewTermsToInclude * showsPerSessionForBox(0);
+ const numReviewTermChallenges = desiredNumChallenges - numNewTermChallenges;
+
+ // select review terms until we max number of challenges
+ const [reviewTerms, reviewChallengesFound] = scanWhile(
+ potentialReviewTerms,
+ (count, term) => count + showsPerSessionForBox(term.box),
+ (count) => count <= numReviewTermChallenges,
+ 0
+ );
+
+ // if there aren't enough terms...
+ if (reviewChallengesFound + numNewTermChallenges < minChallenges) {
+ if (reviewOnly) {
+ console.log(
+ "Not enough review terms for a lesson! But a review only lesson was requested!"
+ );
+ return {
+ type: "ERROR",
+ error: LessonCreationErrorType.NOT_ENOUGH_TERMS_FOR_REVIEW_LESSON,
+ };
+ } else {
+ // try to fill lesson with new terms
+ return createLessonAndFindSetsToAdd(
+ desiredId,
+ desiredNumChallenges,
+ reviewOnly,
+ state,
+ Math.floor(
+ // remaining challenges / challenges per new term
+ (desiredNumChallenges - reviewChallengesFound) /
+ showsPerSessionForBox(0)
+ )
+ );
+ }
+ }
- return act({
- type: "ADD_LESSON",
+ // if we have enough terms...
+ const newTerms = potentialNewTerms.slice(0, numNewTermsToInclude);
+
+ const realNumChallenges =
+ reviewChallengesFound + newTerms.length * showsPerSessionForBox(0);
+
+ const lesson: Lesson = {
+ id: desiredId,
+ terms: [...newTerms, ...reviewTerms.map((t) => t.key)],
+ startedAt: null,
+ completedAt: null,
+ createdAt: Date.now(),
+ createdFor: getToday(),
+ numChallenges: realNumChallenges,
+ type: "DAILY",
+ };
+
+ return {
+ type: "SUCCESS",
+ result: {
lesson,
- });
- });
+ setsToAdd: setsToAdd.map((s) => s.id),
+ },
+ };
}
diff --git a/src/state/reducers/lessons/index.ts b/src/state/reducers/lessons/index.ts
index e4c8c919..e7401868 100644
--- a/src/state/reducers/lessons/index.ts
+++ b/src/state/reducers/lessons/index.ts
@@ -1,20 +1,24 @@
import React, { Dispatch } from "react";
import { getToday } from "../../../utils/dateUtils";
-import { v4 } from "uuid";
import { vocabSets } from "../../../data/vocabSets";
-import { UserState } from "../../UserStateProvider";
-import { Act, ImperativeBlock } from "../../../utils/useReducerWithImperative";
-import { ReviewResult } from "../leitnerBoxes";
-import { createLessonTransaction } from "./createNewLesson";
-import { UserStateAction } from "../../actions";
+import { UserState } from "../../useUserState";
+import { createLessonAndFindSetsToAdd } from "./createNewLesson";
+import { LessonsAction, UserStateAction } from "../../actions";
+import { showsPerSessionForBox } from "../../../spaced-repetition/usePimsleurTimings";
+import { cherokeeToKey } from "../../../data/cards";
+import { logEvent } from "firebase/analytics";
+import { analytics } from "../../../firebase";
+import { useAuth } from "../../../firebase/AuthProvider";
+import { lessonMetadataPath } from "../../../firebase/paths";
+import { set } from "firebase/database";
export interface DailyLesson {
type: "DAILY";
}
-export interface SetLesson {
- type: "SET";
- setId: string;
+export interface PracticeLesson {
+ type: "PRACTICE";
+ includedSets: string[];
}
export interface LessonMixin {
@@ -34,7 +38,7 @@ export interface LessonMixin {
numChallenges: number;
}
-type LessonMeta = SetLesson | DailyLesson;
+type LessonMeta = PracticeLesson | DailyLesson;
export type Lesson = LessonMixin & LessonMeta;
@@ -46,86 +50,126 @@ export function nameForLesson(lesson: Lesson) {
switch (lesson.type) {
case "DAILY":
return `Daily lesson on ${new Date(lesson.createdFor).toDateString()}`;
- case "SET":
- const set = vocabSets[lesson.setId];
- return `Lesson for set '${set.title}'`;
+ case "PRACTICE":
+ return `Practice lesson with ${lesson.includedSets
+ .map((setId) => `'${vocabSets[setId].title}'`)
+ .join(", ")}`;
}
}
export type LessonsState = Record;
export interface LessonsInteractors {
- startLesson: (lessonId: string) => void;
- concludeLesson: (
- lessonId: string,
- reviewedTerms: Record
- ) => void;
createNewLesson: (
desiredId: string,
numChallenges: number,
reviewOnly: boolean
- ) => void;
+ ) => Promise;
+ createPracticeLesson: (
+ desiredId: string,
+ setsToInclude: string[],
+ shuffleTerms: boolean
+ ) => Promise;
}
-export function reduceLessonsState(
- { lessons }: UserState,
- action: UserStateAction
-): Record {
+export function reduceLesson(lesson: Lesson, action: LessonsAction): Lesson {
switch (action.type) {
- case "ADD_LESSON":
- return {
- ...lessons,
- [action.lesson.id]: action.lesson,
- };
case "START_LESSON":
return {
- ...lessons,
- [action.lessonId]: {
- ...lessons[action.lessonId],
- startedAt: Date.now(),
- },
+ ...lesson,
+ startedAt: Date.now(),
};
case "CONCLUDE_LESSON":
return {
- ...lessons,
- [action.lessonId]: {
- ...lessons[action.lessonId],
- completedAt: Date.now(),
- },
+ ...lesson,
+ completedAt: Date.now(),
};
}
- return lessons;
+ return lesson;
}
-export function useLessonsInteractors(
- _state: UserState,
- dispatch: Dispatch,
- dispatchImperativeBlock: Dispatch>
-): LessonsInteractors {
- function createNewLesson(
- desiredId: string,
- numChallenges: number,
- reviewOnly: boolean
- ) {
- dispatchImperativeBlock((state, act) =>
- createLessonTransaction(desiredId, numChallenges, reviewOnly, state, act)
- );
- }
+function shuffled(list: T[]): T[] {
+ return list
+ .map((item) => [Math.random(), item] as const)
+ .sort(([a], [b]) => a - b)
+ .map(([, item]) => item);
+}
+/**
+ * Create a `Lesson` object for practicing specific terms outside of tracked
+ * progress.
+ */
+function practiceLessonForSets(
+ desiredId: string,
+ setsToInclude: string[],
+ shuffleTerms: boolean
+): Lesson {
+ const sets = setsToInclude.map((id) => vocabSets[id]);
+ const terms = sets.flatMap((s) => s.terms);
+ const numChallenges = showsPerSessionForBox(0) * terms.length;
+ const termKeys = terms.map((t) => cherokeeToKey(t));
return {
- startLesson(lessonId) {
- dispatch({
- type: "START_LESSON",
- lessonId,
- });
+ id: desiredId,
+ terms: shuffleTerms ? shuffled(termKeys) : termKeys,
+ startedAt: null,
+ completedAt: null,
+ createdAt: Date.now(),
+ createdFor: getToday(),
+ numChallenges,
+ includedSets: setsToInclude,
+ type: "PRACTICE",
+ };
+}
+
+/**
+ * Global state interactors for lessons;
+ */
+export function useLessonInteractors(
+ state: UserState,
+ dispatch: Dispatch
+): LessonsInteractors {
+ const { user } = useAuth();
+ return {
+ createNewLesson(desiredId, numChallenges, reviewOnly) {
+ const result = createLessonAndFindSetsToAdd(
+ desiredId,
+ numChallenges,
+ reviewOnly,
+ state
+ );
+ if (result.type === "ERROR") {
+ logEvent(analytics, "lesson_creation_error", {
+ error: result.error,
+ reviewOnly,
+ });
+ dispatch({
+ type: "LESSON_CREATE_ERROR",
+ error: {
+ lessonId: desiredId,
+ type: result.error,
+ },
+ });
+ return Promise.reject();
+ } else {
+ result.result.setsToAdd.forEach((setToAdd) =>
+ dispatch({
+ type: "ADD_SET",
+ setToAdd,
+ })
+ );
+ return set(
+ lessonMetadataPath(user, result.result.lesson.id).ref,
+ result.result.lesson
+ );
+ }
},
- concludeLesson(lessonId, reviewedTerms) {
- dispatch({
- type: "CONCLUDE_LESSON",
+ createPracticeLesson(lessonId, setsToInclude, shuffleTerms) {
+ const lesson = practiceLessonForSets(
lessonId,
- reviewedTerms,
- });
+ setsToInclude,
+ shuffleTerms
+ );
+ return set(lessonMetadataPath(user, lesson.id).ref, lesson);
},
- createNewLesson,
};
}
diff --git a/src/state/reducers/phoneticsPreference.ts b/src/state/reducers/phoneticsPreference.ts
index 08b7786c..d3582c34 100644
--- a/src/state/reducers/phoneticsPreference.ts
+++ b/src/state/reducers/phoneticsPreference.ts
@@ -1,25 +1,21 @@
export enum PhoneticsPreference {
NoPhonetics = "NO_PHONETICS",
Simple = "SIMPLE",
- // Detailed = "DETAILED",
+ Detailed = "DETAILED",
}
export const PREFERENCE_LITERATES: Record = {
NO_PHONETICS: "Do not show phonetics if syllabary is shown.",
SIMPLE: "Show phonetics without tone or vowel length. Eg. 'ahyvdagwalosgi'",
- // DETAILED:
- // "Show rich phonetics that show vowel length and tone. Eg. 'a²hyv²²da²gwa²lo¹¹sgi'",
+ DETAILED:
+ "Show rich phonetics that show vowel length and tone. Eg. 'a²hyv²²da²gwa²lo¹¹sgi'",
};
/**
* Decide if phonetics should be shown
*/
-export function showPhonetics(
- preference: PhoneticsPreference | undefined
-): boolean {
- return (
- preference !== undefined && preference !== PhoneticsPreference.NoPhonetics
- );
+export function showPhonetics(preference: PhoneticsPreference | null): boolean {
+ return preference !== null && preference !== PhoneticsPreference.NoPhonetics;
}
export function isPhoneticsPreference(str: string): str is PhoneticsPreference {
diff --git a/src/state/reducers/userSets.ts b/src/state/reducers/userSets.ts
index ce771b9a..758964ef 100644
--- a/src/state/reducers/userSets.ts
+++ b/src/state/reducers/userSets.ts
@@ -1,6 +1,6 @@
import React, { Dispatch, useMemo } from "react";
import { ImperativeBlock } from "../../utils/useReducerWithImperative";
-import { UserState } from "../UserStateProvider";
+import { UserState } from "../useUserState";
import { UserStateAction } from "../actions";
export interface UserSetData {
@@ -11,7 +11,7 @@ export interface UserSetData {
export type UserSetsState = Record;
export function reduceUserSetsState(
- { sets }: UserState,
+ { config: { sets } }: UserState,
action: UserStateAction
): UserSetsState {
switch (action.type) {
diff --git a/src/state/useLesson.ts b/src/state/useLesson.ts
deleted file mode 100644
index 07f31c25..00000000
--- a/src/state/useLesson.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useReviewedTerms } from "../spaced-repetition/useReviewSession";
-import { Lesson } from "./reducers/lessons";
-import { ReviewResult } from "./reducers/leitnerBoxes";
-import { useUserStateContext } from "./UserStateProvider";
-import { LessonCreationError } from "./reducers/lessons/createNewLesson";
-
-interface UseLessonReturn {
- lesson: Lesson;
- reviewedTerms: Record;
- reviewTerm: (term: string, correct: boolean) => void;
- concludeLesson: () => void;
- startLesson: () => void;
-}
-
-/**
- * Maybe this is a great time to use context!?
- */
-export function useLesson(lessonId: string): UseLessonReturn {
- const { lessons, concludeLesson, startLesson } = useUserStateContext();
- const reviewedTerms = useReviewedTerms(lessonId);
- const lesson = lessons[lessonId];
-
- if (!lesson) throw new Error(`Lesson ${lessonId} not found`);
-
- return {
- lesson,
- ...reviewedTerms,
- concludeLesson: () => concludeLesson(lessonId, reviewedTerms.reviewedTerms),
- startLesson: () => startLesson(lessonId),
- };
-}
diff --git a/src/state/useUserState.test.ts b/src/state/useUserState.test.tsx
similarity index 61%
rename from src/state/useUserState.test.ts
rename to src/state/useUserState.test.tsx
index 86d057b8..674470e3 100644
--- a/src/state/useUserState.test.ts
+++ b/src/state/useUserState.test.tsx
@@ -1,4 +1,4 @@
-import { UserState, useUserState } from "./UserStateProvider";
+import { UserState, UserStateProps, useUserState } from "./useUserState";
import { renderHook, act } from "@testing-library/react";
import {
CHEROKEE_LANGUAGE_LESSONS_COLLLECTION,
@@ -7,6 +7,25 @@ import {
} from "../data/vocabSets";
import assert from "assert";
import { TermStats } from "../spaced-repetition/types";
+import { MockAuthProvider } from "../firebase/AuthProvider";
+import { LessonCreationError } from "./reducers/lessons/createNewLesson";
+
+function renderUserStateHook(props: {
+ storedUserState?:
+ | (Omit & {
+ ephemeral?:
+ | { lessonCreationError: LessonCreationError | null }
+ | undefined;
+ })
+ | undefined;
+ initializationProps: UserStateProps;
+}) {
+ return renderHook(() => useUserState(props), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+}
describe("useUserState", () => {
const now = 1661985522163;
@@ -26,15 +45,13 @@ describe("useUserState", () => {
const setToAdd =
collections[CHEROKEE_LANGUAGE_LESSONS_COLLLECTION].sets[0];
- const ref = renderHook(() =>
- useUserState({
- initializationProps: {
- leitnerBoxes: {
- numBoxes: 6,
- },
+ const ref = renderUserStateHook({
+ initializationProps: {
+ leitnerBoxes: {
+ numBoxes: 6,
},
- })
- );
+ },
+ });
act(() => ref.result.current.interactors.addSet(setToAdd.id));
@@ -53,15 +70,21 @@ describe("useUserState", () => {
),
numBoxes: 6,
},
- lessonCreationError: undefined,
- lessons: {},
- sets: {
- [setToAdd.id]: {
- setId: setToAdd.id,
- addedAt: now,
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ config: {
+ sets: {
+ [setToAdd.id]: {
+ setId: setToAdd.id,
+ addedAt: now,
+ },
},
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
},
- upstreamCollection: undefined,
});
});
@@ -79,33 +102,35 @@ describe("useUserState", () => {
nextShowDate: 0,
};
- const ref = renderHook(() =>
- useUserState({
- storedUserState: {
- leitnerBoxes: {
- terms: {
- [termAlreadyAdded]: existingTermStats,
- },
- numBoxes: 6,
+ const ref = renderUserStateHook({
+ storedUserState: {
+ leitnerBoxes: {
+ terms: {
+ [termAlreadyAdded]: existingTermStats,
},
- lessonCreationError: undefined,
- lessons: {},
+ numBoxes: 6,
+ },
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ config: {
sets: {},
- upstreamCollection: undefined,
- groupId: undefined,
- phoneticsPreference: undefined,
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
},
- initializationProps: {
- leitnerBoxes: {
- numBoxes: 6,
- },
+ },
+ initializationProps: {
+ leitnerBoxes: {
+ numBoxes: 6,
},
- })
- );
+ },
+ });
act(() => ref.result.current.interactors.addSet(setToAdd.id));
- assert.deepStrictEqual(ref.result.current.state.sets, {
+ assert.deepStrictEqual(ref.result.current.state.config.sets, {
[setToAdd.id]: {
setId: setToAdd.id,
addedAt: now,
@@ -134,15 +159,21 @@ describe("useUserState", () => {
]),
numBoxes: 6,
},
- lessonCreationError: undefined,
- lessons: {},
- sets: {
- [setToAdd.id]: {
- setId: setToAdd.id,
- addedAt: now,
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ config: {
+ sets: {
+ [setToAdd.id]: {
+ setId: setToAdd.id,
+ addedAt: now,
+ },
},
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
},
- upstreamCollection: undefined,
});
});
});
@@ -152,15 +183,13 @@ describe("useUserState", () => {
const setToAddThenRemove =
collections[CHEROKEE_LANGUAGE_LESSONS_COLLLECTION].sets[0];
- const ref = renderHook(() =>
- useUserState({
- initializationProps: {
- leitnerBoxes: {
- numBoxes: 6,
- },
+ const ref = renderUserStateHook({
+ initializationProps: {
+ leitnerBoxes: {
+ numBoxes: 6,
},
- })
- );
+ },
+ });
act(() => ref.result.current.interactors.addSet(setToAddThenRemove.id));
// we already test to ensure it was added correctly
@@ -174,12 +203,16 @@ describe("useUserState", () => {
terms: {},
numBoxes: 6,
},
- lessonCreationError: undefined,
- lessons: {},
- sets: {},
- upstreamCollection: undefined,
- groupId: undefined,
- phoneticsPreference: undefined,
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ config: {
+ sets: {},
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
+ },
});
});
@@ -197,15 +230,13 @@ describe("useUserState", () => {
assert(cllSetWithAyv);
assert(sswSetWithAyv);
- const ref = renderHook(() =>
- useUserState({
- initializationProps: {
- leitnerBoxes: {
- numBoxes: 6,
- },
+ const ref = renderUserStateHook({
+ initializationProps: {
+ leitnerBoxes: {
+ numBoxes: 6,
},
- })
- );
+ },
+ });
act(() => ref.result.current.interactors.addSet(cllSetWithAyv.id));
act(() => ref.result.current.interactors.addSet(sswSetWithAyv.id));
@@ -228,15 +259,21 @@ describe("useUserState", () => {
),
numBoxes: 6,
},
- lessonCreationError: undefined,
- lessons: {},
- sets: {
- [sswSetWithAyv.id]: {
- setId: sswSetWithAyv.id,
- addedAt: now,
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ config: {
+ sets: {
+ [sswSetWithAyv.id]: {
+ setId: sswSetWithAyv.id,
+ addedAt: now,
+ },
},
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
},
- upstreamCollection: undefined,
});
});
});
diff --git a/src/state/useUserState.ts b/src/state/useUserState.ts
new file mode 100644
index 00000000..66737207
--- /dev/null
+++ b/src/state/useUserState.ts
@@ -0,0 +1,337 @@
+import { useEffect, useMemo } from "react";
+import {
+ LessonsInteractors,
+ LessonsState,
+ useLessonInteractors,
+} from "../state/reducers/lessons";
+import {
+ LeitnerBoxesInteractors,
+ LeitnerBoxState,
+ reduceLeitnerBoxState,
+ useLeitnerBoxesInteractors,
+} from "../state/reducers/leitnerBoxes";
+import { useReducerWithImperative } from "../utils/useReducerWithImperative";
+import {
+ reduceUserSetsState,
+ UserSetsInteractors,
+ UserSetsState,
+ useUserSetsInteractors,
+} from "../state/reducers/userSets";
+import { UserStateAction } from "../state/actions";
+import { LessonCreationError } from "../state/reducers/lessons/createNewLesson";
+import {
+ GroupId,
+ GROUPS,
+ isGroupId,
+ reduceGroupId,
+} from "../state/reducers/groupId";
+import { PhoneticsPreference } from "../state/reducers/phoneticsPreference";
+import { analytics, auth } from "../firebase";
+import { logEvent } from "firebase/analytics";
+import { uploadAllLessonDataFromLocalStorage } from "../firebase/migration";
+
+export interface UserStateProps {
+ leitnerBoxes: {
+ numBoxes: number;
+ };
+}
+
+export interface LegacyUserState {
+ /** Terms the user is learning and ther progress */
+ leitnerBoxes: LeitnerBoxState;
+ /** Lessons that have been created for the user */
+ lessons: LessonsState;
+ /** Latest error describing why a lesson could not be created */
+ lessonCreationError: LessonCreationError | undefined;
+ /** Sets the user is learning */
+ sets: UserSetsState;
+ /** The collection from which new sets should be pulled when the user is ready for new terms */
+ upstreamCollection: string | undefined;
+ /** Group registration */
+ groupId: GroupId | undefined;
+ /** Preference for how phonetics are shown */
+ phoneticsPreference: PhoneticsPreference | undefined;
+ /** Has this state been uploaded to Firestore? */
+ HAS_BEEN_UPLOADED?: true;
+}
+
+/**
+ * Like legacy state but no lessons
+ */
+export interface UserConfig {
+ /** Sets the user is learning */
+ sets: UserSetsState;
+ /** The collection from which new sets should be pulled when the user is ready for new terms */
+ upstreamCollection: string | null;
+ /** Group registration */
+ groupId: GroupId | null;
+ /** Preference for how phonetics are shown */
+ phoneticsPreference: PhoneticsPreference | null;
+ /** Email address to contact the user */
+ userEmail: string | null;
+}
+
+/**
+ * Two slices of user state, managed separately by firebase
+ */
+export interface UserState {
+ /** Simple config and data we are comfortable fetching all at once */
+ config: UserConfig;
+ /** Terms the user is learning and ther progress - split from config for semantic and scaling reasons */
+ leitnerBoxes: LeitnerBoxState;
+ /** Data we don't care about and don't save to the db */
+ ephemeral: {
+ /** Latest error describing why a lesson could not be created */
+ lessonCreationError: LessonCreationError | null;
+ };
+}
+
+interface MiscInteractors {
+ setUpstreamCollection: (collectionId: string) => void;
+ registerGroup: (groupId: string) => void;
+ setPhoneticsPreference: (newPreference: PhoneticsPreference) => void;
+ setUserEmail: (newUserEmail: string) => void;
+ loadState: (state: LegacyUserState) => void;
+}
+
+export type UserInteractors = UserSetsInteractors &
+ LessonsInteractors &
+ LeitnerBoxesInteractors &
+ MiscInteractors;
+
+function reduceUpstreamCollection(
+ { config: { upstreamCollection } }: UserState,
+ action: UserStateAction
+): string | null {
+ if (action.type === "SET_UPSTREAM_COLLECTION") return action.newCollectionId;
+ if (action.type === "REGISTER_GROUP_AND_APPLY_DEFAULTS")
+ // if no upstream collection, set to group default
+ return (
+ upstreamCollection ?? GROUPS[action.groupId].defaultCollectionId ?? null
+ );
+ else return upstreamCollection;
+}
+
+function reducePhoneticsPreference(
+ { config: { phoneticsPreference } }: UserState,
+ action: UserStateAction
+): PhoneticsPreference | null {
+ if (action.type === "SET_PHONETICS_PREFERENCE") return action.newPreference;
+ if (action.type === "REGISTER_GROUP_AND_APPLY_DEFAULTS")
+ // if no preference, set to group default when a user registers
+ return (
+ phoneticsPreference ?? GROUPS[action.groupId].phoneticsPreference ?? null
+ );
+ else return phoneticsPreference;
+}
+
+function reduceUserEmail(
+ { config: { userEmail } }: UserState,
+ action: UserStateAction
+): string | null {
+ if (action.type === "SET_USER_EMAIL") return action.newUserEmail;
+ else return userEmail;
+}
+
+function reduceLessonCreationError(
+ { ephemeral: { lessonCreationError } }: UserState,
+ action: UserStateAction
+): LessonCreationError | null {
+ if (action.type === "LESSON_CREATE_ERROR") return action.error;
+ else return lessonCreationError;
+}
+
+function reduceUserState(state: UserState, action: UserStateAction): UserState {
+ // bail on individual resovlers if loading state
+ if (action.type === "LOAD_STATE") return convertLegacyState(action.state);
+
+ return {
+ config: {
+ sets: reduceUserSetsState(state, action),
+ upstreamCollection: reduceUpstreamCollection(state, action),
+ groupId: reduceGroupId(state, action),
+ phoneticsPreference: reducePhoneticsPreference(state, action),
+ userEmail: reduceUserEmail(state, action),
+ },
+ leitnerBoxes: reduceLeitnerBoxState(state, action),
+ ephemeral: {
+ lessonCreationError: reduceLessonCreationError(state, action),
+ },
+ };
+}
+
+function blankUserState(initializationProps: UserStateProps): UserState {
+ return {
+ config: {
+ sets: {},
+ upstreamCollection: null,
+ groupId: null,
+ phoneticsPreference: null,
+ userEmail: null,
+ },
+ ephemeral: {
+ lessonCreationError: null,
+ },
+ leitnerBoxes: {
+ numBoxes: initializationProps.leitnerBoxes.numBoxes,
+ terms: {},
+ },
+ };
+}
+
+export function convertLegacyState(state: LegacyUserState): UserState {
+ return {
+ config: {
+ sets: state.sets,
+ groupId: state.groupId ?? null,
+ phoneticsPreference: state.phoneticsPreference ?? null,
+ upstreamCollection: state.upstreamCollection ?? null,
+ userEmail: null,
+ },
+ leitnerBoxes: state.leitnerBoxes,
+ ephemeral: { lessonCreationError: state.lessonCreationError ?? null },
+ };
+}
+
+/**
+ * We don't really need the ephemeral parts to initialize the state
+ */
+type InitializerState = Omit & {
+ ephemeral?: UserState["ephemeral"];
+};
+
+function initializeUserState({
+ storedUserState,
+ initializationProps,
+}: {
+ storedUserState?: InitializerState;
+ initializationProps: UserStateProps;
+}): UserState {
+ // This blank state nonsense ensures undefined and missing properties are upgraded to null
+ const blankState = blankUserState(initializationProps);
+ if (storedUserState)
+ // deep assign to each slice
+ return Object.fromEntries(
+ Object.entries(blankState).map(([sliceKey, blankSlice]) => [
+ sliceKey,
+ Object.assign(
+ blankSlice,
+ Object.fromEntries(
+ Object.entries(
+ storedUserState[sliceKey as keyof UserState] ?? {}
+ ).filter(([k, v]) => v !== undefined)
+ )
+ ),
+ ])
+ ) as UserState;
+ else return blankState;
+}
+
+export function useUserState(props: {
+ storedUserState?: InitializerState;
+ initializationProps: UserStateProps;
+}) {
+ const [state, dispatch, dispatchImperativeBlock] = useReducerWithImperative(
+ reduceUserState,
+ props,
+ initializeUserState
+ );
+
+ const userSetsInteractors = useUserSetsInteractors(
+ state,
+ dispatch,
+ dispatchImperativeBlock
+ );
+
+ const lessonsInteractors = useLessonInteractors(state, dispatch);
+
+ const leitnerBoxesInteractors = useLeitnerBoxesInteractors(
+ state,
+ dispatch,
+ dispatchImperativeBlock
+ );
+
+ const miscInteractors: MiscInteractors = useMemo(
+ () => ({
+ setUpstreamCollection(collectionId: string | null) {
+ logEvent(analytics, "change_upstream_collection", {
+ oldCollection: state.config.upstreamCollection,
+ newCollection: collectionId,
+ });
+ dispatch({
+ type: "SET_UPSTREAM_COLLECTION",
+ newCollectionId: collectionId,
+ });
+ },
+ registerGroup(groupId: string) {
+ if (isGroupId(groupId)) {
+ dispatch({
+ type: "REGISTER_GROUP_AND_APPLY_DEFAULTS",
+ groupId,
+ });
+ }
+ },
+ loadState(state: LegacyUserState) {
+ dispatch({
+ type: "LOAD_STATE",
+ state,
+ });
+ dispatch({ type: "HANDLE_SET_CHANGES" });
+ localStorage.setItem("user-state", JSON.stringify(state));
+ auth.currentUser &&
+ uploadAllLessonDataFromLocalStorage(auth.currentUser);
+ },
+ setPhoneticsPreference(newPreference) {
+ logEvent(analytics, "change_phonetics_preference", {
+ oldPreference: state.config.phoneticsPreference,
+ newPreference,
+ });
+ dispatch({
+ type: "SET_PHONETICS_PREFERENCE",
+ newPreference,
+ });
+ },
+ setUserEmail(newUserEmail) {
+ logEvent(analytics, "set_user_email");
+ dispatch({
+ type: "SET_USER_EMAIL",
+ newUserEmail,
+ });
+ },
+ }),
+ []
+ );
+
+ useEffect(() => {
+ // handle resizes in number of boxes if we ever deploy them
+ if (
+ state.leitnerBoxes.numBoxes !==
+ props.initializationProps.leitnerBoxes.numBoxes
+ )
+ leitnerBoxesInteractors.resize(
+ props.initializationProps.leitnerBoxes.numBoxes
+ );
+
+ if (state.config.groupId) {
+ dispatch({
+ groupId: state.config.groupId,
+ type: "REGISTER_GROUP_AND_APPLY_DEFAULTS",
+ });
+ }
+
+ dispatch({ type: "HANDLE_SET_CHANGES" });
+ }, []);
+
+ return {
+ state,
+ interactors: {
+ ...userSetsInteractors,
+ ...lessonsInteractors,
+ ...leitnerBoxesInteractors,
+ ...miscInteractors,
+ },
+ dispatch,
+ };
+}
+
+// context provider
diff --git a/src/theme.ts b/src/theme.ts
index 5cdba149..d63e1803 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -6,6 +6,7 @@ export const theme = {
LIGHT_GRAY: "#D0D0D0",
MED_GRAY: "#A2A2A2",
DARK_RED: "#7A2022",
+ LIGHTEST_RED: "#FFCED0",
MED_GREEN: "#77CE33",
DARK_GREEN: "#427A15",
TEXT_GRAY: "#222222",
diff --git a/src/utils/createIssue.ts b/src/utils/createIssue.ts
deleted file mode 100644
index 1bbb2ba4..00000000
--- a/src/utils/createIssue.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-function audioIssueBody(files: string[], meta: string): string {
- return `# Describe the problem:
-## If an issue with audio please describe:
-Is the problem with the Cherokee or English audio?
-
-What sounds wrong about the audio?
-Eg. "Sounds like someone talking through a fan" or "Sounds like a robot."
-
-
-
-## If an issue of grammar/correctness, please describe:
-Eg. \`di-\` prefix is used, but translation is singular.
-
-
-
-## If an issue with syllabary, please describe:
-Eg. English text in syllabary, or "Ꮝ" used for shorted "Ꮜ"
-
-
-# DO NOT EDIT BELOW
-
-Problematic audio:
-- ${files.join("\n- ")}
-
-Additional metadata:
-\`\`\`json
-${meta}
-\`\`\`
-`;
-}
-
-export function createGithubIssueForAudioInNewTab(
- files: string[],
- meta: string
-) {
- const a = document.createElement("a");
- a.href = `https://github.com/CherokeeLanguage/online-exercises/issues/new?title=${encodeURIComponent(
- "Issue with term"
- )}&projects=CherokeeLanguage/1&labels=${encodeURIComponent(
- "content issue"
- )}&body=${encodeURIComponent(audioIssueBody(files, meta))}`;
- a.target = "_BLANK";
- a.click();
-}
-
-export function createIssueForAudioInNewTab(
- groupId: string | undefined,
- termKey: string
-) {
- const a = document.createElement("a");
- a.href = getGoogleFormsLink(groupId ?? "unregistered", termKey);
- a.target = "_BLANK";
- a.click();
-}
-
-const getGoogleFormsLink = (groupId: string, termKey: string) =>
- `https://docs.google.com/forms/d/e/1FAIpQLSdF0B0g3zLfuhifjDng-7N5H1JWfHNOgxe5SBJiYDluQ7_ORg/viewform?usp=pp_url&entry.1765053485=${groupId}&entry.1353555636=${termKey}`;
diff --git a/src/utils/phonetics.test.ts b/src/utils/phonetics.test.ts
index 66525fca..2d6f5339 100644
--- a/src/utils/phonetics.test.ts
+++ b/src/utils/phonetics.test.ts
@@ -1,26 +1,27 @@
import assert from "assert";
-import { cards } from "../data/cards";
+import { Card, cards, PhoneticOrthography } from "../data/cards";
import { PhoneticsPreference } from "../state/reducers/phoneticsPreference";
import {
+ alignSyllabaryAndPhonetics,
getPhonetics,
mcoToWebsterTones,
normalizeAndRemovePunctuation,
- removeTonesAndMarkers,
+ simplifyMCO,
} from "./phonetics";
-describe("normalization and tone removal", () => {
+describe("MCO", () => {
it.each([
- ["sǔ:dáli", "sǔ:dáli".normalize("NFKD"), "sudali"],
- ["Sa:sa aná:ɂi", "sa:sa aná:ɂi".normalize("NFKD"), "sasa anaɂi"],
+ ["sǔ:dáli", "sǔ:dáli".normalize("NFD"), "sudali"],
+ ["Sa:sa aná:ɂi", "sa:sa aná:ɂi".normalize("NFD"), "sasa anaɂi"],
[
"U:ni:ji:ya dù:hyoha na asgaya",
- "u:ni:tsi:ya dù:hyoha na asgaya".normalize("NFKD"),
+ "u:ni:tsi:ya dù:hyoha na asgaya".normalize("NFD"),
"unitsiya duhyoha na asgaya",
],
[
"na yǒ:na achű:ja já:ni dù:dó:ʔa",
- "na yǒ:na atsű:tsa tsá:ni dù:dó:ɂa".normalize("NFKD"),
- "na yona atsutsa tsani dudoɂa",
+ "na yǒ:na achű:tsa tsá:ni dù:dó:ɂa".normalize("NFD"),
+ "na yona achutsa tsani dudoɂa",
],
])(
"works for a bunch of examples",
@@ -32,7 +33,7 @@ describe("normalization and tone removal", () => {
"Should produce expected normalized output"
);
- const actualSimplified = removeTonesAndMarkers(actualNormalized);
+ const actualSimplified = simplifyMCO(actualNormalized);
assert.deepStrictEqual(
actualSimplified,
expectedSimplified,
@@ -48,10 +49,11 @@ describe("mcoToWebsterTones", () => {
["sadv́:di à:gowhtíha", "sa²dv³³di a¹¹go²whti³ha"],
[
"na yǒ:na achű:ja já:ni dù:dó:ʔa",
- "na yo²³na a²tsu⁴⁴tsa tsa³³ni du¹¹do³³ɂa",
+ "na yo²³na a²chu⁴⁴tsa tsa³³ni du¹¹do³³ɂa",
],
["Ahyv:dagwalò:sgi", "a²hyv²²da²gwa²lo¹¹sgi"],
["Ayv:wi:ya̋", "a²yv²²wi²²ya⁴"],
+ ["Salǒ:li gaɂnǐ:ya na gi:hli.", "sa²lo²³li ga²ɂni²³ya na gi²²hli"],
])(
"converts from diacritic- to superscript-based orthographies",
(mco, expected) => {
@@ -67,9 +69,24 @@ describe("mcoToWebsterTones", () => {
});
describe("getPhonetics", () => {
- it("(simple) removes all diacritics from all terms", () => {
+ it("(simple) removes all diacritics and superscripts from all terms", () => {
const termsWithDiacriticsLeft = cards.reduce((arr, card) => {
const websterTones = getPhonetics(card, PhoneticsPreference.Simple);
+ return websterTones.normalize("NFD").match(/[:\u0300-\u036f¹²³⁴]/g) ===
+ null
+ ? arr
+ : [...arr, `original: ${card.cherokee} -- actual: ${websterTones}`];
+ }, []);
+ assert.deepStrictEqual(
+ termsWithDiacriticsLeft,
+ [],
+ "there should be no terms with diacritics or superscripts left"
+ );
+ });
+
+ it("(detailed) removes all diacritics from all terms", () => {
+ const termsWithDiacriticsLeft = cards.reduce((arr, card) => {
+ const websterTones = getPhonetics(card, PhoneticsPreference.Detailed);
return websterTones.normalize("NFD").match(/[:\u0300-\u036f]/g) === null
? arr
: [...arr, `original: ${card.cherokee} -- actual: ${websterTones}`];
@@ -81,3 +98,109 @@ describe("getPhonetics", () => {
);
});
});
+
+describe("alignSyllabaryAndPhonetics", () => {
+ it.each([
+ ["ᎣᏏᏲ", "osiyo", ["Ꭳ", "Ꮟ", "Ᏺ"], ["o", "si", "yo"]],
+ // the following contain drop vowels
+ [
+ "ᏅᏃᎱᎵᏗ",
+ "nvnohuhldi",
+ ["Ꮕ", "Ꮓ", "Ꮁ", "Ꮅ", "Ꮧ"],
+ ["nv", "no", "hu", "hl", "di"],
+ ],
+ ["ᎠᏍᎦᏯ", "asgay", ["Ꭰ", "Ꮝ", "Ꭶ", "Ꮿ"], ["a", "s", "ga", "y"]],
+ // the below differ only in syllabary spelling
+ ["ᎢᏡᎬᎢ", "ihlgvi", ["Ꭲ", "Ꮱ", "Ꭼ", "Ꭲ"], ["i", "hl", "gv", "i"]],
+ ["ᎢᎵᎬᎢ", "ihlgvi", ["Ꭲ", "Ꮅ", "Ꭼ", "Ꭲ"], ["i", "hl", "gv", "i"]],
+ // more stuff
+ ["ᎤᏛᏛᏁ", "utvdvhne", ["Ꭴ", "Ꮫ", "Ꮫ", "Ꮑ"], ["u", "tv", "dv", "hne"]],
+ ["ᏚᏯ", "tuya", ["Ꮪ", "Ꮿ"], ["tu", "ya"]],
+ // works with / without dropped vowel
+ ["ᏗᏂᏲᏟ", "diniyotl", ["Ꮧ", "Ꮒ", "Ᏺ", "Ꮯ"], ["di", "ni", "yo", "tl"]],
+ ["ᏗᏂᏲᏟ", "diniyotli", ["Ꮧ", "Ꮒ", "Ᏺ", "Ꮯ"], ["di", "ni", "yo", "tli"]],
+ ["ᏩᏯ", "wahya", ["Ꮹ", "Ꮿ"], ["wa", "hya"]],
+ ["ᏩᎭᏯ", "wahya", ["Ꮹ", "Ꭽ", "Ꮿ"], ["wa", "h", "ya"]],
+ // ti / di stuff
+ ["ᏍᏚᏗ", "sdudi", ["Ꮝ", "Ꮪ", "Ꮧ"], ["s", "du", "di"]],
+ [
+ "ᎠᏴᏓᏆᎶᏍᎩ",
+ "a²hyv²²da²gwa²lo¹¹sgi",
+ ["Ꭰ", "Ᏼ", "Ꮣ", "Ꮖ", "Ꮆ", "Ꮝ", "Ꭹ"],
+ ["a²", "hyv²²", "da²", "gwa²", "lo¹¹", "s", "gi"],
+ ],
+ // handle Ꮝ prefixed s sounds
+ ["ᏍᏏᏓᏁᎳ", "sidanela", ["ᏍᏏ", "Ꮣ", "Ꮑ", "Ꮃ"], ["si", "da", "ne", "la"]],
+ ["ᏏᏓᏁᎳ", "sidanela", ["Ꮟ", "Ꮣ", "Ꮑ", "Ꮃ"], ["si", "da", "ne", "la"]],
+ // the following handle fused sounds
+ ["ᏫᎯᏢᎾ", "hwitlvna", ["ᏫᎯ", "Ꮲ", "Ꮎ"], ["hwi", "tlv", "na"]],
+ ["ᏱᎯᏍᏕᎳ", "hyisdela", ["ᏱᎯ", "Ꮝ", "Ꮥ", "Ꮃ"], ["hyi", "s", "de", "la"]],
+ [
+ "ᏱᏍᎩᏍᏕᎳ",
+ "yiksdela",
+ ["Ᏹ", "ᏍᎩ", "Ꮝ", "Ꮥ", "Ꮃ"],
+ ["yi", "k", "s", "de", "la"],
+ ],
+ // sounds are not fused if they do not need to be
+ ["ᏘᏫᎯ", "tiwihi", ["Ꮨ", "Ꮻ", "Ꭿ"], ["ti", "wi", "hi"]],
+ [
+ "ᏍᎩᏃᎯᏍᏏ",
+ "sginohisi",
+ ["Ꮝ", "Ꭹ", "Ꮓ", "Ꭿ", "ᏍᏏ"],
+ ["s", "gi", "no", "hi", "si"],
+ ],
+ ])(
+ "works idk",
+ (syllabary, phonetics, expectedSyllabary, expectedPhonetics) => {
+ const result = alignSyllabaryAndPhonetics(syllabary, phonetics, false);
+ assert.deepStrictEqual(result, [
+ [expectedSyllabary],
+ [expectedPhonetics],
+ ]);
+ }
+ );
+
+ it("doesn't blow up for any terms", () => {
+ const termsThatExploded = cards.reduce((arr, card) => {
+ try {
+ const _res = alignSyllabaryAndPhonetics(
+ card.syllabary,
+ getPhonetics(card, PhoneticsPreference.Detailed),
+ false
+ );
+ } catch (err) {
+ return [...arr, card.cherokee];
+ }
+ return arr;
+ }, []);
+ assert.deepStrictEqual(
+ termsThatExploded,
+ // actively talking to first language speakers about this term:
+ // ["e²²li³³wu³ke³ yi²ki,sde²²la, di²gv²²di²²ye³ʔv²³ʔi²"],
+ [],
+ "there should be no terms that error out"
+ );
+ });
+
+ it("fails gracefully", () => {
+ const [syllabary, phonetics] = alignSyllabaryAndPhonetics(
+ "ᎡᎵᏭᎨ ᏱᏍᎩᏍᏕᎳ ᏗᎬᏗᏰᎥᎢ",
+ "e²²li³³wu³ke³ yi²ksde²²l di²gv²²di²²ye³ɂv²³ɂi²"
+ );
+ assert.deepStrictEqual(
+ [syllabary, phonetics],
+ [
+ [
+ ["Ꭱ", "Ꮅ", "Ꮽ", "Ꭸ"],
+ ["Ᏹ", "ᏍᎩ", "Ꮝ", "Ꮥ", "Ꮃ"],
+ ["Ꮧ", "Ꭼ", "Ꮧ", "Ᏸ", "Ꭵ", "Ꭲ"],
+ ],
+ [
+ ["e²²", "li³³", "wu³", "ke³"],
+ ["yi²", "k", "s", "de²²", "l"],
+ ["di²", "gv²²", "di²²", "ye³", "ɂv²³", "ɂi²"],
+ ],
+ ]
+ );
+ });
+});
diff --git a/src/utils/phonetics.ts b/src/utils/phonetics.ts
index a6322187..82272770 100644
--- a/src/utils/phonetics.ts
+++ b/src/utils/phonetics.ts
@@ -1,59 +1,410 @@
-import { Card } from "../data/cards";
+import { Card, PhoneticOrthography } from "../data/cards";
import { PhoneticsPreference } from "../state/reducers/phoneticsPreference";
export function getPhonetics(
card: Card,
- phoneticsPreference: PhoneticsPreference | undefined
+ phoneticsPreference: PhoneticsPreference | null
): string {
+ // this should be the ONLY time we use NFC -- and it is for user presentation
return getRawPhonetics(card, phoneticsPreference).normalize("NFC");
}
function getRawPhonetics(
card: Card,
- phoneticsPreference: PhoneticsPreference | undefined
+ phoneticsPreference: PhoneticsPreference | null
): string {
switch (phoneticsPreference) {
- // case PhoneticsPreference.Detailed:
- // return mcoToWebsterTones(normalizeAndRemovePunctuation(card.cherokee));
+ case PhoneticsPreference.Detailed:
+ return detailedPhonetics(card);
case PhoneticsPreference.Simple:
- return simplifyPhonetics(card.cherokee);
- case undefined:
+ return simplifyPhonetics(card);
+ case null:
case PhoneticsPreference.NoPhonetics:
return "";
}
}
+function detailedPhonetics({ cherokee, phoneticOrthography }: Card) {
+ switch (phoneticOrthography) {
+ case PhoneticOrthography.MCO:
+ return mcoToWebsterTones(normalizeAndRemovePunctuation(cherokee));
+ case PhoneticOrthography.WEBSTER:
+ return removeDropVowelsWebster(normalizeAndRemovePunctuation(cherokee));
+ }
+}
+
export function normalizeAndRemovePunctuation(cherokee: string): string {
return cherokee
.toLowerCase()
.replaceAll(/[.?]/g, "")
- .replaceAll(/(ch)|(j)/g, "ts")
+ .replaceAll(/j/g, "ts")
.replaceAll(/qu/g, "gw")
.replaceAll(/[Ɂʔ]/g, "ɂ")
- .normalize("NFKD");
+ .replaceAll(/ː/g, ":")
+ .normalize("NFD");
}
export function mcoToWebsterTones(cherokee: string): string {
// ¹²³⁴
// conversion based on Uchihara 2013, p. 14
- return cherokee
- .replaceAll(/\u0304/g, "") // remove macron accents if present (just mark low tone)
- .replaceAll(/([aeiouv])\u0300:/g, "$1¹¹") // combining grave accent, long
- .replaceAll(/([aeiouv]):/g, "$1²²") // long vowel with no diacritic
- .replaceAll(/([aeiouv])\u0301:/g, "$1³³") // combining acute accent, long
- .replaceAll(/([aeiouv])\u030C:/g, "$1²³") // combining caron accent, long
- .replaceAll(/([aeiouv])\u0302:/g, "$1³²") // combining circumflex accent, long
- .replaceAll(/([aeiouv])\u030B:/g, "$1⁴⁴") // combining double acute accent, long
- .replaceAll(/([aeiouv])\u030B/g, "$1⁴") // combining double acute accent, short (in cases where a final vowel is dropped and a highfall tone must become short)
- .replaceAll(/([aeiouv])\u0300/g, "$1¹") // combining grave accent, short
- .replaceAll(/([aeiouv])\u0301/g, "$1³") // combining acute accent, short
- .replaceAll(/([aeiouv])(?![¹²³⁴]|\W|$)/g, "$1²"); // vowel not followed by any tone yet
-}
-
-export function removeTonesAndMarkers(cherokee: string): string {
+ return (
+ cherokee
+ .replaceAll(/\u0304/g, "") // remove macron accents if present (just mark low tone)
+ .replaceAll(/([aeiouv])\u0300:/g, "$1¹¹") // combining grave accent, long
+ .replaceAll(/([aeiouv]):/g, "$1²²") // long vowel with no diacritic
+ .replaceAll(/([aeiouv])\u0301:/g, "$1³³") // combining acute accent, long
+ .replaceAll(/([aeiouv])\u030C:/g, "$1²³") // combining caron accent, long
+ .replaceAll(/([aeiouv])\u0302:/g, "$1³²") // combining circumflex accent, long
+ .replaceAll(/([aeiouv])\u030B:/g, "$1⁴⁴") // combining double acute accent, long
+ .replaceAll(/([aeiouv])\u030B/g, "$1⁴") // combining double acute accent, short (in cases where a final vowel is dropped and a highfall tone must become short)
+ // BEGIN long vowels that need to be shortened because they are last in a word
+ .replaceAll(/([aeiouv])\u030C(?=\s|$)/g, "$1²$2") // combining caron accent, short
+ .replaceAll(/([aeiouv])\u0302(?=\s|$)/g, "$1³$2") // combining circumflex accent, short
+ .replaceAll(/([aeiouv])\u0300/g, "$1¹") // combining grave accent, short
+ .replaceAll(/([aeiouv])\u0301/g, "$1³") // combining acute accent, short
+ .replaceAll(/([aeiouv])(?![¹²³⁴])(?=[ɂ\w])/g, "$1²") // non-final vowel not followed by any tone yet
+ );
+}
+
+export function simplifyMCO(cherokee: string): string {
return cherokee.replace(/[:\u0300-\u036f]/g, "");
}
-export function simplifyPhonetics(cherokee: string): string {
- return removeTonesAndMarkers(normalizeAndRemovePunctuation(cherokee));
+export function removeDropVowelsWebster(cherokee: string): string {
+ // JW's notation for drop vowel
+ return cherokee.replace(/[aeiouv],/g, "");
+}
+
+export function simplifyWebster(cherokee: string): string {
+ return removeDropVowelsWebster(cherokee.replace(/[¹²³⁴]/g, ""));
+}
+
+export function simplifyPhonetics({
+ cherokee,
+ phoneticOrthography,
+}: Card): string {
+ switch (phoneticOrthography) {
+ case PhoneticOrthography.MCO:
+ return simplifyMCO(normalizeAndRemovePunctuation(cherokee));
+ case PhoneticOrthography.WEBSTER:
+ return simplifyWebster(normalizeAndRemovePunctuation(cherokee));
+ }
+}
+
+function prototypicalSyllable(grapheme: string): string {
+ if (grapheme.length == 2) {
+ const secondProto = prototypicalSyllable(grapheme[1]);
+ // handle Ꮝ-prefixed s sounds (eg. ᏍᏏᏉᏯ - sigwoya), by pretending first Ꮝ isn't there
+ if (grapheme[0] === "Ꮝ" && secondProto === "Ꮜ") return "Ꮜ";
+ else return prototypicalSyllable(grapheme[0]) + secondProto;
+ }
+
+ // see https://en.wikipedia.org/wiki/Cherokee_(Unicode_block)
+ if (["Ꮤ", "Ꮨ", "Ꮦ"].includes(grapheme)) return "Ꮤ";
+ if (["Ꭷ", "Ꮝ", "Ꮏ", "Ᏽ", "Ꮬ", "Ꮏ", ".", "?", ",", "!"].includes(grapheme))
+ return grapheme;
+
+ // list MUST BE in reverse alphabetical order (by unicode codepoint)
+ // to reverse sort in vi, select the block and run :sort!
+ const breaks = [
+ "Ꮿ",
+ "Ꮹ",
+ "Ꮳ",
+ "Ꮭ",
+ "Ꮣ",
+ "Ꮜ",
+ "Ꮖ",
+ "Ꮎ",
+ "Ꮉ",
+ "Ꮃ",
+ "Ꭽ",
+ "Ꭶ",
+ "Ꭰ",
+ ];
+ const breakCodes = breaks.map((b) => b.charCodeAt(0));
+ const graphemeCode = grapheme.charCodeAt(0);
+ const hit = breakCodes.findIndex((code) => code <= graphemeCode);
+ if (hit === -1)
+ throw new Error(
+ `Could not find prototypical representation for: ${grapheme}`
+ );
+
+ return breaks[hit];
+}
+
+const syllabarySounds: Record = {
+ ".": /\.?/,
+ ",": /,?/,
+ "?": /\??/,
+ "!": /\!?/,
+ Ꭰ: /[aeiouv][¹²³⁴]*/,
+ Ꭶ: /[gk]([aeiouv][¹²³⁴]*)?/,
+ Ꭷ: /k([aeiouv][¹²³⁴]*)?/,
+ Ꭽ: /h([aeiouv][¹²³⁴]*)?/,
+ Ꮃ: /[ht]?l([aeiouv][¹²³⁴]*)?/,
+ Ꮉ: /m([aeiouv][¹²³⁴]*)?/,
+ Ꮎ: /n([aeiouv][¹²³⁴]*)?/,
+ Ꮏ: /hn([aeiouv][¹²³⁴]*)?/,
+ Ꮖ: /((gw)|(qu)|(kw))([aeiouv][¹²³⁴]*)?/,
+ Ꮝ: /s(?![aeiouv])/, // s not followed by vowel
+ Ꮜ: /s([aeiouv][¹²³⁴]*)?/, // always followed by vowel
+ Ꮣ: /[td]([aeiouv][¹²³⁴]*)?/,
+ Ꮤ: /t([aei][¹²³⁴]*)?/, // ta only represents ta, ti, te
+ Ꮭ: /((tl)|(hl)|(dl))([aeiouv][¹²³⁴]*)?/, // support tla for hla
+ Ꮬ: /((tl)|(hl)|(dl))([aeiouv][¹²³⁴]*)?/, // dla
+ Ꮳ: /((j)|(ts)|(ch))([aeiouv][¹²³⁴]*)?/,
+ Ꮹ: /w([aeiouv][¹²³⁴]*)?/,
+ Ꮿ: /y([aeiouv][¹²³⁴]*)?/,
+
+ // FUSED SOUNDS
+ // dropped Ꮝ sound in sgi/sgw pronoun 2sg>1sg.
+ ᏍᎦ: /[kg]/,
+ ᏍᏆ: /((gw)|(qu)|(kw))/,
+ // fused h from second person set A pronoun
+ ᏯᎭ: /y([aeiouv][¹²³⁴]*)?/,
+ ᏩᎭ: /w([aeiouv][¹²³⁴]*)?/,
+};
+
+function lazyMapFind(
+ items: T[],
+ map: (e: T) => U,
+ predicate: (e: U) => boolean
+): U | undefined {
+ for (const elm of items) {
+ const res = map(elm);
+ if (predicate(res)) return res;
+ }
+}
+
+function matchPrototypicalSyllable({
+ graphemeProto,
+ remainingPhonetics,
+}: {
+ remainingPhonetics: string;
+ graphemeProto: string;
+}): { matchedPhonetics: string; foundAt: number } | undefined {
+ const nextRegexp = syllabarySounds[graphemeProto];
+ if (nextRegexp === undefined)
+ throw new Error(
+ `No regexp found for prototypically grapheme: ${graphemeProto}`
+ );
+ const matchRes = nextRegexp.exec(remainingPhonetics);
+ if (
+ matchRes === null ||
+ (matchRes.index > 0 &&
+ // the following allows for intrusive /h/s ie. unmarked aspiration to be part of any syllable
+ !["h", "ɂ"].includes(remainingPhonetics.substring(0, matchRes.index)))
+ ) {
+ return undefined;
+ }
+
+ const foundAt = matchRes.index;
+ const matchedPhonetics = matchRes[0];
+
+ return { matchedPhonetics, foundAt };
+}
+
+/**
+ * Match a syllabary grapheme against a set of phonetics.
+ * Handles fused sounds by first attempting to match unfused versions.
+ * Returns `undefined` if the grapheme couldn't be matched.
+ */
+function matchGrapheme({
+ syllabaryGrapheme,
+ remainingPhonetics,
+}: {
+ syllabaryGrapheme: string;
+ remainingPhonetics: string;
+}):
+ | {
+ remainingPhonetics: string;
+ splitPhonetics: string[];
+ splitSyllabary: string[];
+ }
+ | undefined {
+ const graphemeProto = prototypicalSyllable(syllabaryGrapheme);
+
+ const thingsToTry =
+ graphemeProto.length === 1
+ ? [[syllabaryGrapheme]]
+ : [syllabaryGrapheme.split(""), [syllabaryGrapheme]];
+
+ const res = lazyMapFind(
+ thingsToTry,
+ (syllabaryGraphemesToMatch) =>
+ syllabaryGraphemesToMatch.reduce<
+ [string, string[], string[]] | undefined
+ >(
+ (accumulator, syllabaryGrapheme) => {
+ if (accumulator === undefined) return undefined;
+
+ const [remainingPhonetics, splitSyllabary, splitPhonetics] =
+ accumulator;
+ const graphemeProto = prototypicalSyllable(syllabaryGrapheme);
+ const match = matchPrototypicalSyllable({
+ remainingPhonetics,
+ graphemeProto,
+ });
+
+ if (match)
+ return [
+ remainingPhonetics.substring(
+ match.matchedPhonetics.length + match.foundAt
+ ),
+ [...splitSyllabary, syllabaryGrapheme],
+ [
+ ...splitPhonetics,
+ remainingPhonetics.substring(
+ 0,
+ match.matchedPhonetics.length + match.foundAt
+ ),
+ ],
+ ];
+ else {
+ return undefined;
+ }
+ },
+ [remainingPhonetics, [], []]
+ ),
+ (e) => e !== undefined
+ );
+
+ if (res) {
+ const [remainingPhonetics, splitSyllabary, splitPhonetics] = res;
+ return { remainingPhonetics, splitSyllabary, splitPhonetics };
+ }
+}
+
+function alignSyllabaryAndPhoneticsWord(
+ syllabary: string,
+ phonetics: string
+): [string[], string[]] {
+ /**
+ * Some speakers/writers will use Ꮝ before any sound starting with s, including Ꮜ,Ꮞ,etc.
+ * This code merges an Ꮝ preceding a Ꮜ family character into one segment
+ */
+ function combinePossiblyFusedSounds(
+ combined: string[],
+ nextCharacter: string
+ ) {
+ const previousCharacter: string | undefined = combined[combined.length - 1];
+ const previousProto: string | undefined =
+ previousCharacter && prototypicalSyllable(previousCharacter);
+ const nextProto = prototypicalSyllable(nextCharacter);
+ // an s syllable proceeded by an Ꮝ can be fused
+ // TODO: can this be solved like /h/ metathesis rules
+ if (previousCharacter === "Ꮝ" && nextProto === "Ꮜ") {
+ return [
+ ...combined.slice(0, combined.length - 1), // chop off prev segment that contained Ꮝ
+ "Ꮝ" + nextCharacter,
+ ];
+ }
+
+ // identify prefix sounds that could fuse a pronominal h
+ if (
+ (previousProto === "Ꮿ" ||
+ previousProto === "Ꮹ" ||
+ previousProto === "Ꮎ") &&
+ nextProto === "Ꭽ"
+ ) {
+ return [
+ ...combined.slice(0, combined.length - 1), // chop off prev segment that contained prefix
+ previousCharacter + nextCharacter,
+ ];
+ }
+
+ // identify ᏍᎩ / ᏍᏆ 2sg -> 1sg pronoun
+ if (
+ previousCharacter === "Ꮝ" &&
+ (nextCharacter === "Ꭹ" || nextProto === "Ꮖ")
+ ) {
+ return [
+ ...combined.slice(0, combined.length - 1), // chop off prev segment that contained Ꮝ
+ "Ꮝ" + nextCharacter,
+ ];
+ }
+
+ return [...combined, nextCharacter];
+ }
+
+ const [_remaining, splitSyllabary, splitPhonetics] = syllabary
+ .trim()
+ .split("")
+ .reduce(combinePossiblyFusedSounds, [])
+ .reduce<[string, string[], string[]]>(
+ (
+ [remainingPhonetics, splitSyllabary, splitPhonetics],
+ syllabaryGrapheme
+ ) => {
+ const match = matchGrapheme({
+ syllabaryGrapheme,
+ remainingPhonetics,
+ });
+
+ if (!match) {
+ throw new Error(
+ `Failed to match ${syllabaryGrapheme} against ${remainingPhonetics}`
+ );
+ }
+
+ return [
+ match.remainingPhonetics,
+ [...splitSyllabary, ...match.splitSyllabary],
+ [...splitPhonetics, ...match.splitPhonetics],
+ ];
+ },
+ [phonetics.trim(), [], []]
+ );
+
+ return [splitSyllabary, splitPhonetics];
+}
+
+/**
+ * Present an array of aligned segments for syllabary and phonetics.
+ * @param syllabary
+ * @param phonetics
+ * @param suppressErrors - if true words that fail to be matched will be aligned with syllabary on a per word basis.
+ * @returns `[syllabaryWords, phoneticsWords]`, where each `*Words` array contains a list of string segements.
+ */
+export function alignSyllabaryAndPhonetics(
+ syllabary: string,
+ phonetics: string,
+ suppressErrors = true
+): [string[][], string[][]] {
+ const syllabaryWords = syllabary
+ .trim()
+ .split(" ")
+ .filter((w) => w.trim() !== "");
+ const phoneticsWords = phonetics
+ .trim()
+ .split(" ")
+ .filter((w) => w.trim() !== "");
+
+ if (syllabaryWords.length !== phoneticsWords.length)
+ throw new Error(
+ `Not the same number of words in syllabary and phonetics!\n\t${syllabary}\n\t${phonetics}`
+ );
+
+ return syllabaryWords.reduce<[string[][], string[][]]>(
+ ([syllabarySplit, phoneticsSplit], syllabaryWord, idx) => {
+ try {
+ const [newSyllabarySplit, newPhoneticsSplit] =
+ alignSyllabaryAndPhoneticsWord(syllabaryWord, phoneticsWords[idx]);
+ return [
+ [...syllabarySplit, newSyllabarySplit],
+ [...phoneticsSplit, newPhoneticsSplit],
+ ];
+ } catch (e) {
+ if (suppressErrors) {
+ return [
+ [...syllabarySplit, [syllabaryWord]],
+ [...phoneticsSplit, [phoneticsWords[idx]]],
+ ];
+ } else {
+ throw e;
+ }
+ }
+ },
+ [[], []]
+ );
}
diff --git a/src/utils/useCardsForTerms.ts b/src/utils/useCardsForTerms.ts
index aca9482b..44c652a9 100644
--- a/src/utils/useCardsForTerms.ts
+++ b/src/utils/useCardsForTerms.ts
@@ -45,7 +45,7 @@ export function useCardsForTerms(
lookupTermsAndCollectMissing(
terms,
allCards,
- (s) => s,
+ (s) => s.normalize("NFD"), // used for keys
keyFn,
(term, card) => card
),
diff --git a/src/utils/useTransition.ts b/src/utils/useTransition.ts
index 351fcd78..f2d1ea77 100644
--- a/src/utils/useTransition.ts
+++ b/src/utils/useTransition.ts
@@ -6,28 +6,49 @@ export interface UseTransitionProps {
export interface UseTransitionReturn {
transitioning: boolean;
- startTransition: (newCallback?: () => void) => void;
+ startTransition: (waitForUser: boolean, newCallback: () => void) => void;
+ endTransition: () => void;
}
-export function useTransition({ duration }: UseTransitionProps) {
+/**
+ * Help a component that has a transition state that is either timed or waits for a user's input.
+ *
+ * To start a timed transition, call startTransition with `waitForUser=false`.
+ * To start a transition that waits until the user interacts use
+ * `waitForUser=true` and then call `endTransition` when the transition should
+ * be released.
+ */
+export function useTransition({
+ duration,
+}: UseTransitionProps): UseTransitionReturn {
const [transitioning, setTransitioning] = useState(false);
const [callback, setCallback] = useState<{ cb?: () => void }>({
cb: undefined,
});
+ const [waitForUser, setWaitForUser] = useState(false);
useEffect(() => {
if (transitioning) {
- const timeout = setTimeout(() => {
- setTransitioning(false);
- callback.cb && callback.cb();
- }, duration);
- return () => window.clearTimeout(timeout);
+ if (!waitForUser) {
+ const timeout = setTimeout(() => {
+ setTransitioning(false);
+ callback.cb && callback.cb();
+ }, duration);
+ return () => window.clearTimeout(timeout);
+ }
+ } else {
}
- }, [transitioning, callback]);
+ }, [transitioning, waitForUser, callback]);
return {
transitioning,
- startTransition: (newCallback?: () => void) => {
+ startTransition(waitForUser, newCallback) {
setTransitioning(true);
+ setWaitForUser(waitForUser);
setCallback({ cb: newCallback });
},
+ endTransition() {
+ // call cb if we have one
+ callback.cb?.();
+ setTransitioning(false);
+ },
};
}
diff --git a/src/views/collections/ViewCollection.tsx b/src/views/collections/ViewCollection.tsx
deleted file mode 100644
index ae18eaf0..00000000
--- a/src/views/collections/ViewCollection.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { ReactElement } from "react";
-import { useParams } from "react-router-dom";
-import { CollectionDetails } from "../../components/CollectionDetails";
-import { collections } from "../../data/vocabSets";
-
-export function ViewCollection(): ReactElement {
- const { collectionId } = useParams();
- if (collectionId === undefined) throw new Error("Must have collection id");
- return ;
-}
-
-export function ViewCollectionPage({
- collectionId,
-}: {
- collectionId: string;
-}): ReactElement {
- const collection = collections[collectionId];
- return ;
-}
diff --git a/src/views/dashboard/ActivityWidget.tsx b/src/views/dashboard/ActivityWidget.tsx
deleted file mode 100644
index d093ae28..00000000
--- a/src/views/dashboard/ActivityWidget.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { SectionHeading } from "../../components/SectionHeading";
-import { useUserStateContext } from "../../state/UserStateProvider";
-import { getToday } from "../../utils/dateUtils";
-
-export function ActivityWidget() {
- const { leitnerBoxes, upstreamCollection } = useUserStateContext();
- const today = getToday();
-
- const termsToStudyToday = Object.values(leitnerBoxes.terms).filter(
- (t) => t.nextShowDate <= today
- );
-
- const termsToReviewToday = termsToStudyToday.filter(
- (t) => t.lastShownDate !== 0
- );
- const newTermsForToday = termsToStudyToday.filter(
- (t) => t.lastShownDate === 0
- );
-
- const termsReviewedToday = Object.values(leitnerBoxes.terms).filter(
- (t) => t.lastShownDate >= today
- );
-
- return (
-
-
Activity
-
- {upstreamCollection === undefined && newTermsForToday.length > 0 && (
-
- You have{" "}
-
- {newTermsForToday.length} new term
- {newTermsForToday.length !== 1 && "s"}
- {" "}
- selected to mix into today's lessons.
-
- )}
- {termsToReviewToday.length > 0 ? (
-
- You have{" "}
-
- {termsToReviewToday.length} term
- {termsToReviewToday.length !== 1 && "s"} to review
- {" "}
- today.
-
- ) : (
-
- You don't have any terms to review today. Sounds like a great time to
- work on new terms!
-
- )}
- {termsReviewedToday.length > 0 ? (
-
- You have already reviewed{" "}
-
- {termsReviewedToday.length} term
- {termsReviewedToday.length !== 1 && "s"}
- {" "}
- today. ᎤᏍᏆᏂᎩᏗ!
-
- ) : (
-
- You haven't reviewed any terms yet today. Click one of the buttons
- below to start learning!
-
- )}
-
- );
-}
diff --git a/src/views/dashboard/Dashboard.tsx b/src/views/dashboard/Dashboard.tsx
index d50601f8..d33c8705 100644
--- a/src/views/dashboard/Dashboard.tsx
+++ b/src/views/dashboard/Dashboard.tsx
@@ -1,20 +1,25 @@
import React, { ReactElement } from "react";
import { LessonsWidget } from "./LessonsWidget";
// import { SetsWidget } from "./SetsWidget";
-import { ActivityWidget } from "./ActivityWidget";
import { GettingStartedWidget } from "./GettingStartedWidget";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useUserStateContext } from "../../providers/UserStateProvider";
import { OPEN_BETA_ID } from "../../state/reducers/groupId";
+import { MinigameWidget } from "./MinigameWidget";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { GetHelpWidget } from "./GetHelpWidget";
export function Dashboard(): ReactElement {
- const { groupId } = useUserStateContext();
+ const {
+ config: { groupId },
+ } = useUserStateContext();
+ useAnalyticsPageName("Dashboard");
return (
{/** Only show getting started for folks in open beta */}
{groupId === OPEN_BETA_ID &&
}
-
+
- {/*
*/}
+
);
}
diff --git a/src/views/dashboard/DashboardWidget.tsx b/src/views/dashboard/DashboardWidget.tsx
index b1fb6212..7638befa 100644
--- a/src/views/dashboard/DashboardWidget.tsx
+++ b/src/views/dashboard/DashboardWidget.tsx
@@ -3,20 +3,7 @@ import styled from "styled-components";
import { SectionHeading } from "../../components/SectionHeading";
const StyledDashboardWidget = styled.div`
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
-`;
-
-const WidgetScrollSection = styled.div`
- overflow-x: auto;
-`;
-
-const CardContainer = styled.div`
- display: flex;
- gap: 16px;
- flex-wrap: nowrap;
- padding: 8px;
+ margin-bottom: 20px;
`;
export interface DashboardWidgetProps {
@@ -31,9 +18,7 @@ export function DashboardWidget({
return (
{title}
-
- {children}
-
+ {children}
);
}
diff --git a/src/views/dashboard/DashboardWidgetCard.tsx b/src/views/dashboard/DashboardWidgetCard.tsx
deleted file mode 100644
index 25fbfbe5..00000000
--- a/src/views/dashboard/DashboardWidgetCard.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { ReactElement, ReactNode } from "react";
-import styled from "styled-components";
-
-export interface DashboardWidgetCardProps {
- title: string;
- children: ReactNode;
- action?: ReactNode;
-}
-
-const Card = styled.div`
- min-width: 200px;
- min-height: 150px;
- box-shadow: 2px 2px 4px #aaa;
- border: 1px solid #aaa;
- border-radius: 8px;
- background: #fff;
- text-align: center;
-`;
-
-const CardContent = styled.div``;
-
-export function DashboardWidgetCard({
- title,
- children,
- action,
-}: DashboardWidgetCardProps): ReactElement {
- return (
-
- {title}
- {children}
- {action}
-
- );
-}
diff --git a/src/views/dashboard/DashboardWidgetCardAction.tsx b/src/views/dashboard/DashboardWidgetCardAction.tsx
deleted file mode 100644
index b898a21f..00000000
--- a/src/views/dashboard/DashboardWidgetCardAction.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { ReactElement, ReactNode } from "react";
-import styled from "styled-components";
-import { StyledAnchor, StyledLink } from "../../components/StyledLink";
-
-export interface DashboardWidgetCardActionProps {
- children: ReactNode;
- onClick: () => void;
-}
-
-export function DashboardWidgetCardAction({
- children,
- onClick,
-}: DashboardWidgetCardActionProps): ReactElement {
- return (
-
- {children}
-
- );
-}
diff --git a/src/views/dashboard/GetHelpWidget.tsx b/src/views/dashboard/GetHelpWidget.tsx
new file mode 100644
index 00000000..dd4f14ac
--- /dev/null
+++ b/src/views/dashboard/GetHelpWidget.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+import { StyledAnchor } from "../../components/StyledLink";
+import { DashboardWidget } from "./DashboardWidget";
+
+export function GetHelpWidget() {
+ return (
+
+
+ If you have any questions about the site, please read over our{" "}
+
+ Frequently Asked Questions
+
+ .
+
+
+ );
+}
diff --git a/src/views/dashboard/GettingStartedWidget.tsx b/src/views/dashboard/GettingStartedWidget.tsx
index 4b8bc7c3..1bb70beb 100644
--- a/src/views/dashboard/GettingStartedWidget.tsx
+++ b/src/views/dashboard/GettingStartedWidget.tsx
@@ -1,31 +1,26 @@
import React from "react";
import { SectionHeading } from "../../components/SectionHeading";
import { StyledLink } from "../../components/StyledLink";
+import { DashboardWidget } from "./DashboardWidget";
export function GettingStartedWidget() {
return (
-
-
Getting started
+
There are four main steps to get started.
Find a set of terms you want to learn in the{" "}
- find new vocabulary {" "}
- section.
+ find new vocabulary section.
- Add those terms to your lessons by clicking "Add set and return to
- dashboard."
+ Add those terms to your lessons by clicking "Start studying this
+ collection."
+
+
+ Return to the Dashboard and click "15 minute lesson with new terms"
+ below.
- Click "15 minute lesson with new terms" below.
- Pick an exercise and start practicing terms!
-
- If you want to work through a collection of sets automatically, without
- having to add each set as it's time to introduce more terms, you can
- click "Pull new terms from this collection" in the{" "}
- find new vocabulary section.
-
-
+
);
}
diff --git a/src/views/dashboard/LessonsWidget.tsx b/src/views/dashboard/LessonsWidget.tsx
index 8f0d025d..e5f7c7ee 100644
--- a/src/views/dashboard/LessonsWidget.tsx
+++ b/src/views/dashboard/LessonsWidget.tsx
@@ -1,38 +1,92 @@
import React, { ReactElement } from "react";
import { ButtonLink } from "../../components/Button";
-import { SectionHeading } from "../../components/SectionHeading";
import { StyledLink } from "../../components/StyledLink";
import { Collection, collections } from "../../data/vocabSets";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+import { NewLessonPath, ViewCollectionPath } from "../../routing/paths";
+import { getToday } from "../../utils/dateUtils";
+import { DashboardWidget } from "./DashboardWidget";
const CHALLENGES_IN_15_MINUTE_LESSON = 90;
export function LessonsWidget(): ReactElement {
- const { upstreamCollection: collectionId } = useUserStateContext();
+ const {
+ leitnerBoxes,
+ config: { upstreamCollection: collectionId },
+ } = useUserStateContext();
+
const upstreamCollection = collectionId
? collections[collectionId]
: undefined;
- function createLessonPath(numChallenges: number, reviewOnly: boolean) {
- return `/lessons/new/${numChallenges}/${reviewOnly}`;
- }
+ const today = getToday();
+
+ const termsToStudyToday = Object.values(leitnerBoxes.terms).filter(
+ (t) => t.nextShowDate <= today
+ );
+
+ const termsToReviewToday = termsToStudyToday.filter(
+ (t) => t.lastShownDate !== 0
+ );
+ const newTermsForToday = termsToStudyToday.filter(
+ (t) => t.lastShownDate === 0
+ );
+
+ const termsReviewedToday = Object.values(leitnerBoxes.terms).filter(
+ (t) => t.lastShownDate >= today
+ );
return (
-
-
Learn now
-
You should try to do at least one lesson with new terms a day.
- {newTermsText(upstreamCollection)}
+
+ {upstreamCollection === undefined && newTermsForToday.length > 0 && (
+
+ You have{" "}
+
+ {newTermsForToday.length} new term
+ {newTermsForToday.length !== 1 && "s"}
+ {" "}
+ selected to mix into today's lessons.
+
+ )}
+ {termsToReviewToday.length > 0 ? (
+
+ You have{" "}
+
+ {termsToReviewToday.length} term
+ {termsToReviewToday.length !== 1 && "s"} to review
+ {" "}
+ today.
+
+ ) : (
+
+ You don't have any terms to review today. Sounds like a great time to
+ work on new terms!
+
+ )}
+ {termsReviewedToday.length > 0 ? (
+
+ You have already reviewed{" "}
+
+ {termsReviewedToday.length} term
+ {termsReviewedToday.length !== 1 && "s"}
+ {" "}
+ today. ᎤᏍᏆᏂᎩᏗ!
+
+ ) : (
+
+ You haven't reviewed any terms yet today. Click one of the buttons
+ below to start learning!
+
+ )}
-
+
15 minute lesson with new terms
-
+
15 minute review session
-
+
);
}
@@ -41,7 +95,7 @@ function newTermsText(upstreamCollection: Collection | undefined) {
return (
Right now, new terms come from the{" "}
-
+
{upstreamCollection.title}
{" "}
collection.
diff --git a/src/views/dashboard/MinigameWidget.tsx b/src/views/dashboard/MinigameWidget.tsx
new file mode 100644
index 00000000..05d69d0b
--- /dev/null
+++ b/src/views/dashboard/MinigameWidget.tsx
@@ -0,0 +1,42 @@
+import React, { ReactElement } from "react";
+import { useNavigate } from "react-router";
+import { v4 } from "uuid";
+import { Button } from "../../components/Button";
+import { collections, JW_LIVING_PHRASES } from "../../data/vocabSets";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+import { DashboardWidget } from "./DashboardWidget";
+
+export function MinigameWidget(): ReactElement {
+ const { createPracticeLesson } = useUserStateContext();
+ const navigate = useNavigate();
+
+ function startToneMinigame() {
+ const id = v4();
+ createPracticeLesson(
+ id,
+ collections[JW_LIVING_PHRASES].sets.map((s) => s.id),
+ true
+ );
+ navigate(`/practice/${id}/fill-in-the-tone`);
+ }
+
+ function startWordMinigame() {
+ const id = v4();
+ createPracticeLesson(
+ id,
+ collections[JW_LIVING_PHRASES].sets.map((s) => s.id),
+ true
+ );
+ navigate(`/practice/${id}/fill-in-the-word`);
+ }
+
+ return (
+
+ Play a game and work with the language - no setup required!
+
+ startToneMinigame()}>Fill in the tone
+ startWordMinigame()}>Fill in the word
+
+
+ );
+}
diff --git a/src/views/dashboard/SetsWidget.tsx b/src/views/dashboard/SetsWidget.tsx
deleted file mode 100644
index e239cf50..00000000
--- a/src/views/dashboard/SetsWidget.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React, { ReactElement } from "react";
-import { DashboardWidget } from "./DashboardWidget";
-import { DashboardWidgetCard } from "./DashboardWidgetCard";
-import {
- collections,
- CHEROKEE_LANGUAGE_LESSONS_COLLLECTION,
-} from "../../data/vocabSets";
-import { useNavigate } from "react-router-dom";
-import { DashboardWidgetCardAction } from "./DashboardWidgetCardAction";
-import { useUserStateContext } from "../../state/UserStateProvider";
-import { StyledLink } from "../../components/StyledLink";
-import { ButtonLink } from "../../components/Button";
-
-export function SetsWidget(): ReactElement {
- const cll1 = collections[CHEROKEE_LANGUAGE_LESSONS_COLLLECTION];
- const { sets } = useUserStateContext();
- return (
-
- {cll1.sets
- .filter((s) => !(s.id in sets))
- .map((set, idx) => (
- View set
- }
- >
- {set.terms.length} terms
-
- ))}
-
- );
-}
diff --git a/src/views/lessons/LessonArchive.tsx b/src/views/lessons/LessonArchive.tsx
index a1358df6..7e5e913d 100644
--- a/src/views/lessons/LessonArchive.tsx
+++ b/src/views/lessons/LessonArchive.tsx
@@ -1,18 +1,37 @@
-import { DateTime, Duration } from "luxon";
import React, { ReactElement } from "react";
+import { Duration } from "luxon";
+import { SmallLoader } from "../../components/Loader";
import { SectionHeading } from "../../components/SectionHeading";
import { StyledLink } from "../../components/StyledLink";
import { StyledTable } from "../../components/StyledTable";
import { VisuallyHidden } from "../../components/VisuallyHidden";
+import { useAuth } from "../../firebase/AuthProvider";
+import {
+ useAnalyticsPageName,
+ useFirebaseAllLessonMetadata,
+} from "../../firebase/hooks";
import { Lesson, nameForLesson } from "../../state/reducers/lessons";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { ViewLessonPath } from "../../routing/paths";
-type FinishedLesson = Lesson & { startedAt: number; completedAt: number };
+type FinishedLesson = Lesson & { completedAt: number };
export function LessonArchive(): ReactElement {
- const { lessons } = useUserStateContext();
+ useAnalyticsPageName("Lesson archive");
+ const { user } = useAuth();
+
+ const [firebaseResult, _] = useFirebaseAllLessonMetadata(user);
+
+ if (!firebaseResult.ready)
+ return (
+
+
+
+ );
+
+ const lessons = firebaseResult.data ?? {};
+
const finishedLessons = Object.values(lessons)
- .filter((l): l is FinishedLesson => Boolean(l.completedAt && l.startedAt))
+ .filter((l): l is FinishedLesson => Boolean(l.completedAt))
// most recent first
.sort((a, b) => b.completedAt - a.completedAt);
return (
@@ -56,16 +75,18 @@ function FinishedLessonRow({ lesson }: { lesson: FinishedLesson }) {
{nameForLesson(lesson)}
{lesson.numChallenges || "Unknown number of challenges"}
- {Duration.fromObject({
- milliseconds: lesson.completedAt - lesson.startedAt,
- })
- .shiftTo("minutes", "seconds", "milliseconds")
- .mapUnits((x, u) => (u === "milliseconds" ? 0 : x))
- .shiftTo("minutes", "seconds")
- .toHuman()}
+ {lesson.startedAt
+ ? Duration.fromObject({
+ milliseconds: lesson.completedAt - lesson.startedAt,
+ })
+ .shiftTo("minutes", "seconds", "milliseconds")
+ .mapUnits((x, u) => (u === "milliseconds" ? 0 : x))
+ .shiftTo("minutes", "seconds")
+ .toHuman()
+ : "--"}
- Details
+ Details
);
diff --git a/src/views/lessons/NewLesson.tsx b/src/views/lessons/NewLesson.tsx
index cdbbaa4d..0b1ee562 100644
--- a/src/views/lessons/NewLesson.tsx
+++ b/src/views/lessons/NewLesson.tsx
@@ -1,13 +1,15 @@
-import { ReactElement, useEffect, useMemo } from "react";
-import { Navigate, useParams } from "react-router-dom";
+import { ReactElement, useEffect, useMemo, useState } from "react";
+import { Navigate, useNavigate, useParams } from "react-router-dom";
import { v4 } from "uuid";
import { SectionHeading } from "../../components/SectionHeading";
import { StyledLink } from "../../components/StyledLink";
+import { useAnalyticsPageName } from "../../firebase/hooks";
import {
LessonCreationError,
LessonCreationErrorType,
} from "../../state/reducers/lessons/createNewLesson";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+import { NewLessonPath } from "../../routing/paths";
export function NewLesson() {
const {
@@ -23,17 +25,27 @@ export function NewLesson() {
const newLessonId = useMemo(() => v4(), [numChallenges, reviewOnly]);
- const { createNewLesson, lessons, lessonCreationError } =
- useUserStateContext();
+ const navigate = useNavigate();
+
+ const {
+ createNewLesson,
+ ephemeral: { lessonCreationError },
+ } = useUserStateContext();
const lessonError =
lessonCreationError?.lessonId === newLessonId ? lessonCreationError : null;
+ const [creatingLesson, setCreatingLesson] = useState(false);
+
useEffect(() => {
- if (!(newLessonId in lessons)) {
- createNewLesson(newLessonId, numChallenges, reviewOnly);
+ if (!creatingLesson) {
+ setCreatingLesson(true);
+ createNewLesson(newLessonId, numChallenges, reviewOnly)
+ .then(() => navigate(`/practice/${newLessonId}`))
+ // if this fails, it will update state
+ .catch();
}
- }, [lessons, newLessonId]);
+ }, [newLessonId]);
if (lessonError)
return (
@@ -42,8 +54,6 @@ export function NewLesson() {
);
- else if (newLessonId in lessons)
- return ;
else return Creating lesson... ;
}
@@ -54,6 +64,7 @@ function ErrorAdvice({
error: LessonCreationError;
numChallenges: number;
}): ReactElement {
+ useAnalyticsPageName("Lesson creation error");
switch (error.type) {
case LessonCreationErrorType.NOT_ENOUGH_NEW_TERMS_FOR_LESSON:
return (
@@ -61,7 +72,7 @@ function ErrorAdvice({
There are not enough new terms for a lesson.
Consider adding some{" "}
- new terms or wrapping up
+ new terms or wrapping up
for the day!
>
@@ -74,7 +85,7 @@ function ErrorAdvice({
Consider doing a lesson with{" "}
-
+
some new vocabulary
.
diff --git a/src/views/lessons/ViewLesson.tsx b/src/views/lessons/ViewLesson.tsx
index b018f612..2a91f295 100644
--- a/src/views/lessons/ViewLesson.tsx
+++ b/src/views/lessons/ViewLesson.tsx
@@ -1,18 +1,29 @@
import React, { ReactElement } from "react";
-import { Navigate, useParams } from "react-router-dom";
+import { Navigate, useNavigate, useParams } from "react-router-dom";
import { Card, cards, keyForCard } from "../../data/cards";
import { nameForLesson } from "../../state/reducers/lessons";
import { ReviewResult } from "../../state/reducers/leitnerBoxes";
import { useCardsForTerms } from "../../utils/useCardsForTerms";
-import { useLesson } from "../../state/useLesson";
+import { LessonProvider, useLesson } from "../../providers/LessonProvider";
import { SectionHeading } from "../../components/SectionHeading";
import { CardTable } from "../../components/CardTable";
import { StyledLink } from "../../components/StyledLink";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { LessonsPath } from "../../routing/paths";
export function ViewLesson(): ReactElement {
+ useAnalyticsPageName("View lesson");
const { lessonId } = useParams();
- if (!lessonId) return ;
- return <_ViewLesson lessonId={lessonId} />;
+ const navigate = useNavigate();
+ if (!lessonId) return ;
+ return (
+ navigate("/lessons", { replace: true })}
+ >
+ <_ViewLesson />
+
+ );
}
const reviewResultNames: Record = {
@@ -21,8 +32,8 @@ const reviewResultNames: Record = {
REPEAT_MISTAKE: "Multiple mistakes",
};
-export function _ViewLesson({ lessonId }: { lessonId: string }): ReactElement {
- const { lesson, reviewedTerms } = useLesson(lessonId);
+export function _ViewLesson(): ReactElement {
+ const { lesson, reviewedTerms } = useLesson();
const reviewedCards = useCardsForTerms(
cards,
Object.keys(reviewedTerms),
diff --git a/src/views/practice/PickExercise.tsx b/src/views/practice/PickExercise.tsx
index b82838e7..53a63810 100644
--- a/src/views/practice/PickExercise.tsx
+++ b/src/views/practice/PickExercise.tsx
@@ -1,24 +1,37 @@
import styled from "styled-components";
import { ButtonLink } from "../../components/Button";
import { SectionHeading } from "../../components/SectionHeading";
+import { useLesson } from "../../providers/LessonProvider";
+import { useAnalyticsPageName } from "../../firebase/hooks";
import { theme } from "../../theme";
import { exercises } from "./PracticeLesson";
const ExercisesWrapper = styled.div`
- display: flex;
- flex-wrap: wrap;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
gap: 16px;
+ @media screen and (max-width: 800px) {
+ grid-template-columns: 1fr;
+ }
`;
+/**
+ * Show a list of options for execises a user can do to complete their vocab lesson.
+ */
export function PickExercise() {
+ const { lesson } = useLesson();
+ useAnalyticsPageName("Pick exercise");
return (
Pick an exercise
- {exercises.map((exercise, idx) => (
-
- ))}
+ {exercises
+ // minigames are only allowed for _practice_ lessons
+ .filter((e) => lesson.type === "PRACTICE" || !e.minigame)
+ .map((exercise, idx) => (
+
+ ))}
);
diff --git a/src/views/practice/PracticeLesson.tsx b/src/views/practice/PracticeLesson.tsx
index 0ab2f22b..ae2ad959 100644
--- a/src/views/practice/PracticeLesson.tsx
+++ b/src/views/practice/PracticeLesson.tsx
@@ -1,18 +1,23 @@
import { ReactElement } from "react";
-import { Route, Routes, useParams } from "react-router-dom";
+import { Route, Routes, useNavigate, useParams } from "react-router-dom";
import {
Exercise,
ExerciseComponentProps,
} from "../../components/exercises/Exercise";
import { SimilarTerms } from "../../components/exercises/SimilarTerms";
import { SimpleFlashcards } from "../../components/exercises/SimpleFlashcards";
+import { FillInTheTone } from "../../components/exercises/FillInTheTone";
import { PickExercise } from "./PickExercise";
+import { LessonProvider } from "../../providers/LessonProvider";
+import { FillInTheBlank, FillIntheWord } from "../../components/exercises/FillInWordTemplate";
export const exercises: {
path: string;
name: string;
description: string;
Component: (props: ExerciseComponentProps) => ReactElement;
+ // set to true if game is a minigame that does not require the user to have vocab
+ minigame?: boolean;
}[] = [
{
path: "flashcards",
@@ -28,22 +33,45 @@ export const exercises: {
"Practice terms by listening to Cherokee audio and choosing between similar sounding answers. Often takes less time than flashcards, but often leads to more mistakes.",
Component: SimilarTerms,
},
+ {
+ path: "fill-in-the-tone",
+ name: "Fill in the tone",
+ description:
+ "Practice your tone accuracy by filling in the tone sequence for the missing word in the term.",
+ Component: FillInTheTone,
+ minigame: true,
+ },{
+ path: "fill-in-the-word",
+ name: "Fill in the word",
+ description:
+ "Practice your tone accuracy by filling in the missing word in the term.",
+ Component: FillIntheWord,
+ minigame: true,
+ },
];
export function PracticeLesson(): ReactElement {
const { lessonId } = useParams();
// TODO: navigate instead
if (lessonId === undefined) throw new Error("Must have a lesson to practice");
+ const navigate = useNavigate();
return (
-
- } />
- {exercises.map(({ path, Component }, idx) => (
- }
- />
- ))}
-
+ navigate("/")}
+ >
+
+ } />
+ {exercises.map(({ path, Component, name }, idx) => (
+ }
+ />
+ ))}
+
+
);
}
+
+
diff --git a/src/views/sets/BrowseSets.tsx b/src/views/sets/BrowseSets.tsx
deleted file mode 100644
index 1f24a7ef..00000000
--- a/src/views/sets/BrowseSets.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { ReactElement } from "react";
-import { CollectionDetails } from "../../components/CollectionDetails";
-import { SectionHeading } from "../../components/SectionHeading";
-import { collections } from "../../data/vocabSets";
-
-export function BrowseSets(): ReactElement {
- return (
-
-
Find new vocabulary
-
- Here you can find new vocab sets. If it seems like a set is missing,
- check the "Your sets" tab to see if you are already practicing.
-
- {Object.values(collections).map((collection, idx) => (
-
- ))}
-
- );
-}
diff --git a/src/views/settings/Settings.tsx b/src/views/settings/Settings.tsx
index 88fa3064..aae28eba 100644
--- a/src/views/settings/Settings.tsx
+++ b/src/views/settings/Settings.tsx
@@ -3,12 +3,14 @@ import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { Button } from "../../components/Button";
import { SectionHeading } from "../../components/SectionHeading";
+import { useAnalyticsPageName } from "../../firebase/hooks";
import { lessonKey } from "../../state/reducers/lessons";
import {
isPhoneticsPreference,
PREFERENCE_LITERATES,
} from "../../state/reducers/phoneticsPreference";
-import { UserState, useUserStateContext } from "../../state/UserStateProvider";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+import { UserState } from "../../state/useUserState";
interface ExportedLessonData {
lessonId: string;
@@ -17,10 +19,28 @@ interface ExportedLessonData {
}
export function Settings() {
+ const {
+ config: { userEmail },
+ } = useUserStateContext();
+ useAnalyticsPageName("Settings");
return (
+
User identity
+
+ We have your email on file as: {userEmail}
+
+
+
+ Wrong address? Contact the maintainer at{" "}
+
+ charliemcvicker@protonmail.com
+
+
+
+
+
@@ -40,7 +60,10 @@ const PreferencesForm = styled.form`
`;
function Preferences() {
- const { setPhoneticsPreference, phoneticsPreference } = useUserStateContext();
+ const {
+ setPhoneticsPreference,
+ config: { phoneticsPreference },
+ } = useUserStateContext();
const phoneticsPreferenceId = useId();
function onPhoneticsPreferenceChanged(event: ChangeEvent) {
@@ -58,7 +81,7 @@ function Preferences() {
Phonetics preference
{Object.entries(PREFERENCE_LITERATES).map(([value, literate], i) => (
@@ -83,12 +106,9 @@ function ImportExportDataConsole() {
// state, and need to add that key here.
const fieldsToSave: Record = {
leitnerBoxes: null,
- lessonCreationError: null,
- lessons: null,
- sets: null,
- upstreamCollection: null,
- groupId: null,
- phoneticsPreference: null,
+ // lessons: null,
+ ephemeral: null,
+ config: null,
};
const stateToSave = Object.keys(fieldsToSave).reduce(
@@ -96,19 +116,19 @@ function ImportExportDataConsole() {
{}
);
- const lessonData: ExportedLessonData[] = Object.keys(userState.lessons).map(
- (lessonId) => ({
- lessonId,
- reviewedTerms: window.localStorage.getItem(
- lessonKey(lessonId) + "/reviewed-terms"
- ),
- timings: window.localStorage.getItem(lessonKey(lessonId) + "/timings"),
- })
- );
+ // const lessonData: ExportedLessonData[] = Object.keys(userState).map(
+ // (lessonId) => ({
+ // lessonId,
+ // reviewedTerms: window.localStorage.getItem(
+ // lessonKey(lessonId) + "/reviewed-terms"
+ // ),
+ // timings: window.localStorage.getItem(lessonKey(lessonId) + "/timings"),
+ // })
+ // );
const dataStr =
"data:text/json;charset=utf-8," +
- encodeURIComponent(JSON.stringify({ ...stateToSave, lessonData }));
+ encodeURIComponent(JSON.stringify({ ...stateToSave /** lessonData */ }));
const dlAnchorElem = document.createElement("a");
dlAnchorElem.setAttribute("href", dataStr);
@@ -131,7 +151,7 @@ function ImportExportDataConsole() {
fileToLoad.text().then((data) => {
const { lessonData, ...state } = JSON.parse(data);
// load lesson data
- lessonData.forEach((exported: ExportedLessonData) => {
+ (lessonData ?? []).forEach((exported: ExportedLessonData) => {
if (exported.reviewedTerms) {
window.localStorage.setItem(
lessonKey(exported.lessonId) + "/reviewed-terms",
@@ -148,7 +168,9 @@ function ImportExportDataConsole() {
});
// load larger user state
- userState.loadState(state);
+ // userState.loadState(state);
+ localStorage.setItem("user-state", JSON.stringify(state));
+
navigate("/");
});
}
diff --git a/src/views/terms/MyTerms.tsx b/src/views/terms/MyTerms.tsx
index 9db225ec..b96f7a99 100644
--- a/src/views/terms/MyTerms.tsx
+++ b/src/views/terms/MyTerms.tsx
@@ -6,10 +6,12 @@ import {
TermsByProficiencyLevelChart,
} from "../../components/TermsByProficiencyLevelChart";
import { cards, keyForCard } from "../../data/cards";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { useUserStateContext } from "../../providers/UserStateProvider";
import { useCardsForTerms } from "../../utils/useCardsForTerms";
export function MyTerms(): ReactElement {
+ useAnalyticsPageName("My terms");
const {
leitnerBoxes: { terms },
} = useUserStateContext();
diff --git a/src/views/vocabulary/BrowseCollections.tsx b/src/views/vocabulary/BrowseCollections.tsx
new file mode 100644
index 00000000..17f79d41
--- /dev/null
+++ b/src/views/vocabulary/BrowseCollections.tsx
@@ -0,0 +1,82 @@
+import React, { ReactElement, useMemo } from "react";
+import { CollectionDetails } from "../../components/CollectionDetails";
+import { SectionHeading } from "../../components/SectionHeading";
+import { Collection, collections } from "../../data/vocabSets";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+
+export function BrowseCollections(): ReactElement {
+ useAnalyticsPageName("Find new vocabulary");
+ const {
+ config: { sets, upstreamCollection: upstreamCollectionId },
+ } = useUserStateContext();
+ // split collections into three:
+ // - collection user is learning now
+ // - collections user could start learning
+ // - collections user has finished learning
+ const { upstreamCollection, availableCollections, completedCollections } =
+ useMemo(() => {
+ const { availableCollections, completedCollections } = Object.values(
+ collections
+ )
+ .filter((c) => c.id !== upstreamCollectionId)
+ .reduce<{
+ availableCollections: Collection[];
+ completedCollections: Collection[];
+ }>(
+ ({ availableCollections, completedCollections }, collection) =>
+ collection.sets.every((set) => set.id in sets)
+ ? {
+ availableCollections,
+ completedCollections: [...completedCollections, collection],
+ }
+ : {
+ availableCollections: [...availableCollections, collection],
+ completedCollections,
+ },
+ { availableCollections: [], completedCollections: [] }
+ );
+ return {
+ upstreamCollection: upstreamCollectionId
+ ? collections[upstreamCollectionId]
+ : undefined,
+ availableCollections,
+ completedCollections,
+ };
+ }, [upstreamCollectionId, sets]);
+ return (
+
+
Find new vocabulary
+
+ Here you can find new collections of vocabulary. Click the title of a
+ collection to see the lessons and terms contained.
+
+
+ {upstreamCollection && (
+ <>
+
Learning now
+
+ >
+ )}
+
Unlearned collections
+ {availableCollections.length > 0 ? (
+ availableCollections.map((collection, idx) => (
+
+ ))
+ ) : (
+
+ There are no new available collections. Great job working through all
+ our content!
+
+ )}
+ {completedCollections.length > 0 && (
+ <>
+
Completed collections
+ {completedCollections.map((collection, idx) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
diff --git a/src/views/sets/MySets.tsx b/src/views/vocabulary/MySets.tsx
similarity index 77%
rename from src/views/sets/MySets.tsx
rename to src/views/vocabulary/MySets.tsx
index c340d9ea..97e218e2 100644
--- a/src/views/sets/MySets.tsx
+++ b/src/views/vocabulary/MySets.tsx
@@ -3,10 +3,14 @@ import { SectionHeading } from "../../components/SectionHeading";
import { StyledLink } from "../../components/StyledLink";
import { VocabSetTable } from "../../components/VocabSetTable";
import { vocabSets } from "../../data/vocabSets";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { useUserStateContext } from "../../providers/UserStateProvider";
export function MySets(): ReactElement {
- const { sets } = useUserStateContext();
+ useAnalyticsPageName("My sets");
+ const {
+ config: { sets },
+ } = useUserStateContext();
const userSets = Object.values(sets)
.sort((a, b) => a.addedAt - b.addedAt)
.map((metadata) => vocabSets[metadata.setId]);
@@ -26,7 +30,7 @@ export function MySets(): ReactElement {
) : (
You haven't started learning any vocab sets yet. Browse available sets
- and find new vocabulary to
+ and find new vocabulary to
get started.
)}
diff --git a/src/views/vocabulary/ViewCollection.tsx b/src/views/vocabulary/ViewCollection.tsx
new file mode 100644
index 00000000..1470bbc3
--- /dev/null
+++ b/src/views/vocabulary/ViewCollection.tsx
@@ -0,0 +1,82 @@
+import React, { ReactElement } from "react";
+import { useParams } from "react-router-dom";
+import {
+ MakeUpstreamCollectionButton,
+ StyledCollectionHeader,
+ UpstreamCollectionFlare,
+} from "../../components/CollectionDetails";
+import { StyledLink } from "../../components/StyledLink";
+import { StyledTable } from "../../components/StyledTable";
+import { VisuallyHidden } from "../../components/VisuallyHidden";
+import { collections } from "../../data/vocabSets";
+import { useUserStateContext } from "../../providers/UserStateProvider";
+import { CollectionCredits } from "../../components/CollectionCredits";
+import { useAnalyticsPageName } from "../../firebase/hooks";
+import { ViewSetPath } from "../../routing/paths";
+
+export function ViewCollection(): ReactElement {
+ const { collectionId } = useParams();
+ if (collectionId === undefined) throw new Error("Must have collection id");
+ return ;
+}
+
+export function ViewCollectionPage({
+ collectionId,
+}: {
+ collectionId: string;
+}): ReactElement {
+ const collection = collections[collectionId];
+ useAnalyticsPageName(`View collection (${collection.title})`);
+ const {
+ config: { sets, upstreamCollection },
+ } = useUserStateContext();
+ return (
+
+
+ {collection.title}
+
+ {upstreamCollection === collection.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
Sets in this collection
+
+
+
+ Name
+ Number of terms
+ Started learning
+
+ Link to view set
+
+
+
+
+ {collection.sets.map((set, i) => {
+ return (
+
+ {set.title}
+ {set.terms.length}
+
+
+ {set.id in sets
+ ? new Date(sets[set.id].addedAt).toDateString()
+ : "-"}
+
+
+
+ View details
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/views/sets/ViewSet.tsx b/src/views/vocabulary/ViewSet.tsx
similarity index 52%
rename from src/views/sets/ViewSet.tsx
rename to src/views/vocabulary/ViewSet.tsx
index ffd8cd8c..906ee4eb 100644
--- a/src/views/sets/ViewSet.tsx
+++ b/src/views/vocabulary/ViewSet.tsx
@@ -3,24 +3,28 @@ import { Navigate, useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { Button } from "../../components/Button";
import { CardTable } from "../../components/CardTable";
-import { Modal } from "../../components/Modal";
import { SectionHeading } from "../../components/SectionHeading";
import { cards, keyForCard } from "../../data/cards";
import { collections, VocabSet, vocabSets } from "../../data/vocabSets";
-import { UserSetData } from "../../state/reducers/userSets";
-import { useUserStateContext } from "../../state/UserStateProvider";
+import { useUserStateContext } from "../../providers/UserStateProvider";
import { useCardsForTerms } from "../../utils/useCardsForTerms";
+import { CollectionCredits } from "../../components/CollectionCredits";
+import { ConfirmationModal } from "../../components/ConfirmationModal";
+import { BuildPracticeLessonModal } from "../../components/BuildPracticeLessonModal";
+import { useAnalyticsPageName } from "../../firebase/hooks";
export function ViewSet(): ReactElement {
const { setId } = useParams();
- if (!setId) return ;
+ if (!setId) return ;
const set = vocabSets[setId];
return <_ViewSet set={set} />;
}
-const RemoveSetWrapper = styled.div`
+const SetActionsWrapper = styled.div`
text-align: center;
margin-bottom: 16px;
+ display: flex;
+ justify-content: space-around;
`;
const StyledHeadingWithButton = styled.div`
@@ -36,15 +40,21 @@ const StyledHeadingWithButton = styled.div`
`;
function _ViewSet({ set }: { set: VocabSet }): ReactElement {
- const { addSet, sets, removeSet } = useUserStateContext();
- const [modalIsOpen, setModalOpen] = useState(false);
+ const [removeSetModalOpen, setRemoveSetModalOpen] = useState(false);
+ const [buildPracticeLessonModalOpen, setBuildPracticeLessonModalOpen] =
+ useState(false);
+ useAnalyticsPageName(`Set view (${set.id})`);
+ const {
+ addSet,
+ removeSet,
+ config: { sets },
+ } = useUserStateContext();
- const userSetData = sets[set.id] as UserSetData | undefined;
+ const isLearningSet = set.id in sets;
const navigate = useNavigate();
const setCards = useCardsForTerms(cards, set.terms, keyForCard);
- const collectionName =
- (set.collection && collections[set.collection].title) ?? "";
+ const collection = collections[set.collection];
function addSetAndRedirect() {
addSet(set.id);
@@ -60,10 +70,10 @@ function _ViewSet({ set }: { set: VocabSet }): ReactElement {
- {collectionName && `${collectionName} - `}
+ {collection.title && `${collection.title} - `}
{set.title}
- {userSetData ? (
+ {isLearningSet ? (
(you are already learning this set)
) : (
@@ -71,17 +81,35 @@ function _ViewSet({ set }: { set: VocabSet }): ReactElement {
)}
-
+
Collection description
+
+
Terms in this set
-
- setModalOpen(true)}>
- Stop practicing these terms
+
+
+ setBuildPracticeLessonModalOpen(true)}>
+ Practice just these terms
-
- {modalIsOpen && (
+ {set.id in sets && (
+
setRemoveSetModalOpen(true)}
+ variant="negative"
+ >
+ Stop learning these terms
+
+ )}
+
+
+ {buildPracticeLessonModalOpen && (
+
setBuildPracticeLessonModalOpen(false)}
+ />
+ )}
+ {removeSetModalOpen && (
setModalOpen(false)}
+ close={() => setRemoveSetModalOpen(false)}
confirm={removeSetAndRedirect}
/>
)}
@@ -99,7 +127,13 @@ function ConfirmRemoveSetModal({
confirm: () => void;
}): ReactElement {
return (
-
+ Delete all data on up to {set.terms.length} terms>}
+ >
If you remove this set, all your data on terms from this set will be
deleted.
@@ -108,9 +142,6 @@ function ConfirmRemoveSetModal({
You may still see terms from this set if they are in another set you are
learning.
-
- Delete all data on up to {set.terms.length} terms
-
-
+
);
}