171 lines
4.4 KiB
TypeScript
171 lines
4.4 KiB
TypeScript
/**
|
|
* Import flashcard data from JSON files into Supabase.
|
|
*
|
|
* Usage:
|
|
* SUPABASE_URL=https://xxx.supabase.co SUPABASE_SERVICE_ROLE_KEY=ey... npx tsx supabase/scripts/import-flashcards.ts
|
|
*
|
|
* Requires: @supabase/supabase-js (already in project deps)
|
|
*/
|
|
|
|
import { createClient } from "@supabase/supabase-js"
|
|
import { readFileSync, existsSync } from "fs"
|
|
import { resolve, dirname } from "path"
|
|
import { fileURLToPath } from "url"
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const DATA_DIR = resolve(__dirname, "../data")
|
|
|
|
const supabaseUrl = process.env.SUPABASE_URL
|
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !serviceRoleKey) {
|
|
console.error("Missing env vars: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY")
|
|
process.exit(1)
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
|
|
|
interface RawList {
|
|
id: string
|
|
title: string
|
|
is_public: boolean
|
|
type: "parent" | "term"
|
|
parent_id: string | null
|
|
child_ids: string[]
|
|
}
|
|
|
|
interface RawTerm {
|
|
word: string
|
|
part_of_speech: string | null
|
|
phonetic: string | null
|
|
definition: string | null
|
|
example: string | null
|
|
image_url: string | null
|
|
audio_tts_text: string | null
|
|
audio_lang: string | null
|
|
display_order: number
|
|
}
|
|
|
|
function readJson<T>(path: string): T {
|
|
return JSON.parse(readFileSync(path, "utf-8"))
|
|
}
|
|
|
|
async function importLists(lists: RawList[]) {
|
|
// Insert parent lists first (parent_id = null), then children
|
|
const parents = lists.filter((l) => l.parent_id === null)
|
|
const children = lists.filter((l) => l.parent_id !== null)
|
|
|
|
for (const batch of [parents, children]) {
|
|
const rows = batch.map((l) => ({
|
|
id: Number(l.id),
|
|
title: l.title,
|
|
is_public: l.is_public,
|
|
type: l.type,
|
|
parent_id: l.parent_id ? Number(l.parent_id) : null,
|
|
}))
|
|
|
|
if (rows.length === 0) continue
|
|
|
|
const { error } = await supabase.from("flashcard_list").upsert(rows, {
|
|
onConflict: "id",
|
|
})
|
|
|
|
if (error) {
|
|
console.error(`Error inserting lists batch:`, error.message)
|
|
} else {
|
|
console.log(`Imported ${rows.length} lists`)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function importTerms(listId: number) {
|
|
const filePath = resolve(DATA_DIR, `terms/flashcard_terms_${listId}.json`)
|
|
if (!existsSync(filePath)) {
|
|
console.log(` No terms file for list ${listId}, skipping`)
|
|
return 0
|
|
}
|
|
|
|
const terms = readJson<RawTerm[]>(filePath)
|
|
if (terms.length === 0) {
|
|
console.log(` List ${listId}: empty, skipping`)
|
|
return 0
|
|
}
|
|
|
|
const rows = terms.map((t) => ({
|
|
list_id: listId,
|
|
word: t.word,
|
|
part_of_speech: t.part_of_speech,
|
|
phonetic: t.phonetic,
|
|
definition: t.definition,
|
|
example: t.example,
|
|
image_url: t.image_url,
|
|
audio_tts_text: t.audio_tts_text,
|
|
audio_lang: t.audio_lang || "en-US",
|
|
display_order: t.display_order,
|
|
}))
|
|
|
|
// Supabase has a row limit per request, batch in chunks of 500
|
|
const BATCH_SIZE = 500
|
|
let imported = 0
|
|
|
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
const chunk = rows.slice(i, i + BATCH_SIZE)
|
|
const { error } = await supabase.from("flashcard_term").insert(chunk)
|
|
|
|
if (error) {
|
|
console.error(` List ${listId} batch ${i}: ${error.message}`)
|
|
} else {
|
|
imported += chunk.length
|
|
}
|
|
}
|
|
|
|
console.log(` List ${listId}: ${imported}/${terms.length} terms`)
|
|
return imported
|
|
}
|
|
|
|
async function updateTotalWords(listId: number) {
|
|
const { count } = await supabase
|
|
.from("flashcard_term")
|
|
.select("*", { count: "exact", head: true })
|
|
.eq("list_id", listId)
|
|
|
|
if (count !== null) {
|
|
await supabase
|
|
.from("flashcard_list")
|
|
.update({ total_words: count })
|
|
.eq("id", listId)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log("=== Flashcard Import ===\n")
|
|
|
|
// 1. Import lists
|
|
const listsPath = resolve(DATA_DIR, "flashcard_lists.json")
|
|
const lists = readJson<RawList[]>(listsPath)
|
|
console.log(`Found ${lists.length} lists`)
|
|
await importLists(lists)
|
|
|
|
// 2. Import terms for each list
|
|
console.log("\nImporting terms...")
|
|
let totalTerms = 0
|
|
|
|
for (const list of lists) {
|
|
const count = await importTerms(Number(list.id))
|
|
totalTerms += count
|
|
}
|
|
|
|
// 3. Update total_words counts
|
|
console.log("\nUpdating word counts...")
|
|
for (const list of lists) {
|
|
await updateTotalWords(Number(list.id))
|
|
}
|
|
|
|
console.log(`\nDone! ${lists.length} lists, ${totalTerms} terms imported.`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Fatal:", err)
|
|
process.exit(1)
|
|
})
|