diff --git a/LearningPlatform/.gitignore b/LearningPlatform/.gitignore index 7547adf..a09be91 100644 --- a/LearningPlatform/.gitignore +++ b/LearningPlatform/.gitignore @@ -52,3 +52,6 @@ public/media # private local-only import data scripts/imports/data-private/**/*.js + +# shareable course bundles (export from your machine) +content-bundles/ diff --git a/LearningPlatform/app/(admin)/admin/actions/media.ts b/LearningPlatform/app/(admin)/admin/actions/media.ts index 1c0e2fc..5be82c5 100644 --- a/LearningPlatform/app/(admin)/admin/actions/media.ts +++ b/LearningPlatform/app/(admin)/admin/actions/media.ts @@ -5,6 +5,37 @@ import config from '@payload-config' import { requireAdmin } from '@/lib/auth-helpers' import { revalidatePath } from 'next/cache' import { logActivity, ActivityAction } from '@/lib/activity-log' +import { collectUsedMediaIds } from '@/lib/media-usage' + +const MEDIA_PAGE_SIZE = 100 + +async function listAllMediaIds(): Promise { + const payload = await getPayload({ config }) + const ids: string[] = [] + let page = 1 + + while (true) { + const result = await payload.find({ + collection: 'media', + limit: MEDIA_PAGE_SIZE, + page, + depth: 0, + }) + for (const doc of result.docs) { + ids.push(String(doc.id)) + } + if (!result.hasNextPage) break + page++ + } + + return ids +} + +export async function countUnusedMedia(): Promise { + await requireAdmin() + const [allIds, usedIds] = await Promise.all([listAllMediaIds(), collectUsedMediaIds()]) + return allIds.filter((id) => !usedIds.has(id)).length +} export async function deleteMedia(id: string) { const admin = await requireAdmin() @@ -16,12 +47,39 @@ export async function deleteMedia(id: string) { }) logActivity({ - action: ActivityAction.MEDIA_DELETED, - actorUserId: admin.id, - actorEmail: admin.email, + action: ActivityAction.MEDIA_DELETED, + actorUserId: admin.id, + actorEmail: admin.email, resourceType: 'media', - resourceId: String(id), + resourceId: String(id), }) revalidatePath('/admin/media') } + +export async function deleteAllUnusedMedia(): Promise<{ deleted: number }> { + const admin = await requireAdmin() + const payload = await getPayload({ config }) + const [allIds, usedIds] = await Promise.all([listAllMediaIds(), collectUsedMediaIds()]) + const unusedIds = allIds.filter((id) => !usedIds.has(id)) + + let deleted = 0 + for (const id of unusedIds) { + await payload.delete({ collection: 'media', id }) + deleted++ + } + + if (deleted > 0) { + logActivity({ + action: ActivityAction.MEDIA_DELETED, + actorUserId: admin.id, + actorEmail: admin.email, + resourceType: 'media', + resourceId: 'bulk-unused', + metadata: { count: deleted, mediaIds: unusedIds.slice(0, 50) }, + }) + } + + revalidatePath('/admin/media') + return { deleted } +} diff --git a/LearningPlatform/app/(admin)/admin/dashboard/page.tsx b/LearningPlatform/app/(admin)/admin/dashboard/page.tsx index 0db2a79..d2be94e 100644 --- a/LearningPlatform/app/(admin)/admin/dashboard/page.tsx +++ b/LearningPlatform/app/(admin)/admin/dashboard/page.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { ReloadButton } from '@/components/ui/reload-button' import { CoursesList } from '@/components/admin/courses-list' import { AddCourseButton } from '@/components/admin/add-course-button' +import { ContentBundleImportPanel } from '@/components/admin/content-bundle-import-panel' import { BookOpen, Users, GraduationCap } from 'lucide-react' import { adminGlassCard } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' @@ -105,6 +106,8 @@ export default async function AdminDashboardPage() { + + Courses diff --git a/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx b/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx index 8a450af..7c35e6c 100644 --- a/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx +++ b/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx @@ -832,6 +832,16 @@ export function AdminFlashcardsPage() { setError(typeof data?.error === 'string' ? data.error : 'Could not delete deck.') return } + const removedDeckIds = new Set([deck.id]) + if (opts.isMain) { + for (const row of deckRows) { + if (row.parentDeckId === deck.id) removedDeckIds.add(row.id) + } + } + setFlashcards((prev) => prev.filter((fc) => !removedDeckIds.has(fc.deck.id))) + setDeckRows((prev) => + prev.filter((row) => row.id !== deck.id && row.parentDeckId !== deck.id), + ) if (inlineSubdeckMainId === deck.id) setInlineSubdeckMainId(null) if (inlineStandaloneSubdeckMainId === deck.id) { setInlineStandaloneSubdeckMainId(null) diff --git a/LearningPlatform/app/(admin)/admin/media/page.tsx b/LearningPlatform/app/(admin)/admin/media/page.tsx index 34c913e..9231177 100644 --- a/LearningPlatform/app/(admin)/admin/media/page.tsx +++ b/LearningPlatform/app/(admin)/admin/media/page.tsx @@ -1,6 +1,5 @@ import { getPayload } from 'payload' import config from '@payload-config' -import { unstable_cache } from 'next/cache' import { Card, CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' import { adminGlassCard } from '@/lib/student-glass-styles' @@ -11,7 +10,14 @@ import { payloadTableExists } from '@/lib/payload-utils' import { ReloadButton } from '@/components/ui/reload-button' import Image from 'next/image' import { MediaDeleteButton } from '@/components/admin/media-delete-button' +import { MediaDeleteUnusedButton } from '@/components/admin/media-delete-unused-button' import { MediaUploader } from '@/components/admin/media-uploader' +import { + getMediaUsageForIds, + isMediaInUse, + mediaUsageTotal, + type MediaUsage, +} from '@/lib/media-usage' export const dynamic = 'force-dynamic' @@ -31,136 +37,6 @@ interface Media { url: string } -interface MediaUsage { - lessonsCount: number - tasksCount: number - coursesCount: number - lessonRefs: Array<{ id: number; title: string }> - taskRefs: Array<{ id: number; lessonId: number; lessonTitle: string }> - courseRefs: Array<{ id: number | string; title: string }> -} - -type LessonDoc = { - id: number - title: string - theoryBlocks?: Array> -} - -function coverMediaIdFromCourse(course: Record): string { - const ci = course.coverImage - if (ci == null || ci === '') return '' - if (typeof ci === 'number' || typeof ci === 'string') return String(ci) - if (typeof ci === 'object' && ci !== null && 'id' in ci) return String((ci as { id: unknown }).id) - return '' -} - -// PERFORMANCE: Cache heavy collection scans — revalidate every 30 s. -const getCachedLessonsTasksCourses = unstable_cache( - async () => { - const payload = await getPayload({ config }) - const [lessonsResult, tasksResult, coursesResult] = await Promise.all([ - payload.find({ collection: 'lessons', limit: 1000, depth: 0 }), - payload.find({ collection: 'tasks', limit: 5000, depth: 1 }), - payload.find({ collection: 'courses', limit: 500, depth: 0 }), - ]) - return { lessons: lessonsResult.docs, tasks: tasksResult.docs, courses: coursesResult.docs } - }, - ['admin-media-usage-data-v2'], - { revalidate: 30 }, -) - -// PERFORMANCE FIX: Build usage map for ALL media at once (not per-item) -// This prevents fetching 1000 lessons × 100 media = 100,000 fetches! -async function getAllMediaUsage(mediaIds: Array): Promise> { - const usageMap = new Map() - - // Initialize all media with zero usage - for (const id of mediaIds) { - usageMap.set(String(id), { - lessonsCount: 0, - tasksCount: 0, - coursesCount: 0, - lessonRefs: [], - taskRefs: [], - courseRefs: [], - }) - } - - const { lessons, tasks, courses } = await getCachedLessonsTasksCourses() - - // Build lesson usage map - for (const lesson of lessons as LessonDoc[]) { - if (lesson.theoryBlocks && Array.isArray(lesson.theoryBlocks)) { - for (const block of lesson.theoryBlocks) { - const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } - if (typedBlock.blockType === 'image') { - const imageId = typeof typedBlock.image === 'number' - ? String(typedBlock.image) - : typeof typedBlock.image === 'string' - ? typedBlock.image - : String(typedBlock.image?.id) - - const usage = usageMap.get(imageId) - if (usage) { - usage.lessonsCount++ - if (usage.lessonRefs.length < 3) { - usage.lessonRefs.push({ - id: lesson.id as number, - title: lesson.title, - }) - } - } - } - } - } - } - - // Build task usage map - for (const task of tasks) { - const taskItem = task as Record - const questionMediaId = String(taskItem.questionMedia ?? '') - const solutionMediaId = String(taskItem.solutionMedia ?? '') - const lessonValue = taskItem.lesson as { id?: number; title?: string } | number | undefined - - const taskRef = { - id: taskItem.id as number, - lessonId: typeof lessonValue === 'number' ? lessonValue : lessonValue?.id ?? 0, - lessonTitle: typeof lessonValue === 'object' ? lessonValue?.title ?? 'Untitled' : 'Untitled', - } - - if (questionMediaId && usageMap.has(questionMediaId)) { - const usage = usageMap.get(questionMediaId)! - usage.tasksCount++ - if (usage.taskRefs.length < 3) { - usage.taskRefs.push(taskRef) - } - } - - if (solutionMediaId && solutionMediaId !== questionMediaId && usageMap.has(solutionMediaId)) { - const usage = usageMap.get(solutionMediaId)! - usage.tasksCount++ - if (usage.taskRefs.length < 3) { - usage.taskRefs.push(taskRef) - } - } - } - - for (const course of courses as Array>) { - const coverId = coverMediaIdFromCourse(course) - if (!coverId || !usageMap.has(coverId)) continue - const usage = usageMap.get(coverId)! - usage.coursesCount++ - if (usage.courseRefs.length < 3) { - usage.courseRefs.push({ - id: course.id as number | string, - title: String(course.title ?? 'Course'), - }) - } - } - - return usageMap -} - export default async function AdminMediaPage({ searchParams }: PageProps) { const sp = await searchParams const requestedPage = Math.max(1, parseInt(sp.page || '1', 10) || 1) @@ -173,7 +49,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { let totalPages = 1 try { - // Check if table exists before querying const tableExists = await payloadTableExists('media') if (!tableExists) { error = '⚠️ The database is not initialized. Run migrations: npm run payload:migrate' @@ -200,10 +75,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { }) } - const mediaData = findResult.docs - - // Cast to proper Media type - media = mediaData.map((item) => { + media = findResult.docs.map((item) => { const mediaItem = item as Record return { id: mediaItem.id as number | string, @@ -216,15 +88,14 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { } }) - // PERFORMANCE FIX: Get usage stats for items on this page at once (not per-item) - // Scans cached lessons/tasks/courses once; only resolves usage for visible media ids. - const usageMap = await getAllMediaUsage(media.map(m => m.id)) + const usageMap = await getMediaUsageForIds(media.map((m) => m.id)) mediaWithUsage = media.map((item: Media) => ({ ...item, - usage: usageMap.get(String(item.id)) || { + usage: usageMap.get(String(item.id)) ?? { lessonsCount: 0, tasksCount: 0, coursesCount: 0, + flashcardsCount: 0, lessonRefs: [], taskRefs: [], courseRefs: [], @@ -234,7 +105,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { } catch (err) { console.error('Failed to fetch media:', err) const errorMessage = err instanceof Error ? err.message : String(err) - + if (errorMessage.includes('relation') && errorMessage.includes('does not exist')) { error = '⚠️ The database is not initialized. Run migrations: npm run payload:migrate' } else { @@ -254,9 +125,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { return 'other' } - const totalUsed = mediaWithUsage.filter( - (m) => m.usage.lessonsCount > 0 || m.usage.tasksCount > 0 || m.usage.coursesCount > 0, - ).length + const totalUsed = mediaWithUsage.filter((m) => isMediaInUse(m.usage)).length return (
@@ -270,7 +139,8 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { : 'All uploaded files')}

-
+
+
@@ -289,12 +159,8 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {
-

- No media yet -

-

- Upload a file from a lesson or task editor. -

+

No media yet

+

Upload a file from a lesson or task editor.

@@ -303,17 +169,14 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {
{mediaWithUsage.map((item) => { const mediaType = getMediaType(item.mimeType) - const isUsed = - item.usage.lessonsCount > 0 || item.usage.tasksCount > 0 || item.usage.coursesCount > 0 - const usageTotal = - item.usage.lessonsCount + item.usage.tasksCount + item.usage.coursesCount + const isUsed = isMediaInUse(item.usage) + const usageTotal = mediaUsageTotal(item.usage) return ( - {/* Preview */} {item.mimeType.split('/')[1]?.toUpperCase()}
)} - - {/* Type badge */} - + + {mediaType === 'image' ? 'Image' : mediaType === 'video' ? 'Video' : 'Other'} - {/* Usage badge */} {isUsed && ( - - In use - + + In use + )} - {/* Filename */}

{item.filename} @@ -379,15 +233,13 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {

- {/* File info */}
{item.filesize ? formatFileSize(item.filesize) : 'N/A'} {new Date(item.createdAt).toLocaleDateString('en-US')}
- {/* Usage info */}
-
+
{item.usage.lessonsCount > 0 && ( 📚 {item.usage.lessonsCount} {item.usage.lessonsCount === 1 ? 'lesson' : 'lessons'} @@ -404,6 +256,12 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { {item.usage.coursesCount === 1 ? 'course cover' : 'course covers'} )} + {item.usage.flashcardsCount > 0 && ( + + 🃏 {item.usage.flashcardsCount}{' '} + {item.usage.flashcardsCount === 1 ? 'flashcard' : 'flashcards'} + + )} {!isUsed && ( Unused @@ -411,7 +269,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { )}
- {/* References */} {(item.usage.lessonRefs.length > 0 || item.usage.taskRefs.length > 0 || item.usage.courseRefs.length > 0) && ( @@ -439,7 +296,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { {ref.title} ))} - + {item.usage.taskRefs.map((ref) => ( )}
- - {/* click the preview to open the media in a new tab */} ) @@ -506,7 +361,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { )} - {/* Summary (current page only when paginated) */}

{totalDocs > MEDIA_PER_PAGE ? 'This page' : 'Library'} @@ -518,12 +372,16 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {

- {media.filter(m => m.mimeType.startsWith('image/')).length} + + {media.filter((m) => m.mimeType.startsWith('image/')).length} + images
- {media.filter(m => m.mimeType.startsWith('video/')).length} + + {media.filter((m) => m.mimeType.startsWith('video/')).length} + videos
diff --git a/LearningPlatform/app/api/flashcard-decks/[id]/route.ts b/LearningPlatform/app/api/flashcard-decks/[id]/route.ts index 7922356..4d54b11 100644 --- a/LearningPlatform/app/api/flashcard-decks/[id]/route.ts +++ b/LearningPlatform/app/api/flashcard-decks/[id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { revalidateTag } from 'next/cache' import { prisma } from '@/lib/prisma' import { requireAdmin } from '@/lib/auth-helpers' @@ -43,6 +44,12 @@ export async function DELETE(_req: Request, { params }: RouteContext) { }) } + try { + revalidateTag('api-flashcards', 'max') + } catch { + /* best-effort */ + } + return NextResponse.json({ ok: true }) } catch (error) { if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) { diff --git a/LearningPlatform/components/admin/content-bundle-import-panel.tsx b/LearningPlatform/components/admin/content-bundle-import-panel.tsx new file mode 100644 index 0000000..78f8f2d --- /dev/null +++ b/LearningPlatform/components/admin/content-bundle-import-panel.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { FolderInput, Loader2, RefreshCw } from 'lucide-react' +import { adminGlassCard } from '@/lib/student-glass-styles' +import { cn } from '@/lib/utils' + +type IncomingBundle = { + name: string + courseTitle?: string + courseSlug?: string + exportedAt?: string +} + +export function ContentBundleImportPanel() { + const router = useRouter() + const [bundles, setBundles] = useState([]) + const [incomingDir, setIncomingDir] = useState('') + const [loadingList, setLoadingList] = useState(true) + const [importing, setImporting] = useState(null) + const [error, setError] = useState(null) + + const loadBundles = useCallback(async () => { + setLoadingList(true) + setError(null) + try { + const res = await fetch('/api/admin/content-bundles/incoming') + const data = (await res.json()) as { + error?: string + incomingDir?: string + bundles?: IncomingBundle[] + } + if (!res.ok) throw new Error(data.error || 'Failed to load bundles') + setIncomingDir(data.incomingDir || '') + setBundles(data.bundles || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load bundles') + setBundles([]) + } finally { + setLoadingList(false) + } + }, []) + + useEffect(() => { + void loadBundles() + }, [loadBundles]) + + const handleImport = async (folderName: string, label: string) => { + const confirmed = confirm( + `Import course bundle "${label}"?\n\nExisting content with the same slugs may be updated.`, + ) + if (!confirmed) return + + setImporting(folderName) + try { + const res = await fetch('/api/admin/content-bundles/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folderName }), + }) + const data = (await res.json()) as { error?: string; assetsCopied?: number } + if (!res.ok) throw new Error(data.error || 'Import failed') + + const mediaNote = + typeof data.assetsCopied === 'number' && data.assetsCopied > 0 + ? ` (${data.assetsCopied} image${data.assetsCopied === 1 ? '' : 's'} copied)` + : '' + + alert(`Course bundle imported successfully${mediaNote}.`) + router.refresh() + await loadBundles() + } catch (err) { + alert(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`) + } finally { + setImporting(null) + } + } + + return ( + + +
+ + Import course bundle + + + Unzip an exported course into{' '} + content-bundles/incoming/{' '} + (folder name becomes the bundle id), then import it here. + +
+ +
+ + {incomingDir ? ( +

+ Watching: {incomingDir} +

+ ) : null} + + {error ?

{error}

: null} + + {loadingList ? ( +
+ + Loading bundles… +
+ ) : bundles.length === 0 ? ( +
+ +

No bundles in the incoming folder yet.

+

+ Example: content-bundles/incoming/my-math-course/ +

+
+ ) : ( +
    + {bundles.map((bundle) => { + const label = bundle.courseTitle || bundle.name + return ( +
  • +
    +

    {label}

    +

    {bundle.name}

    + {bundle.courseSlug || bundle.exportedAt ? ( +

    + {bundle.courseSlug ? `Slug: ${bundle.courseSlug}` : null} + {bundle.courseSlug && bundle.exportedAt ? ' · ' : null} + {bundle.exportedAt + ? `Exported: ${new Date(bundle.exportedAt).toLocaleString('en-US')}` + : null} +

    + ) : null} +
    + +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/LearningPlatform/components/admin/course-export-button.tsx b/LearningPlatform/components/admin/course-export-button.tsx new file mode 100644 index 0000000..f31d3e0 --- /dev/null +++ b/LearningPlatform/components/admin/course-export-button.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Download, Loader2 } from 'lucide-react' +type CourseExportButtonProps = { + courseId: string + courseSlug: string + disabled?: boolean +} + +export function CourseExportButton({ courseId, courseSlug, disabled }: CourseExportButtonProps) { + const [loading, setLoading] = useState(false) + + const handleExport = async () => { + setLoading(true) + try { + const res = await fetch(`/api/admin/content-bundles/export/${encodeURIComponent(courseId)}`) + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(data?.error || 'Export failed') + } + + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') + let filename = `${courseSlug}-bundle.zip` + const match = disposition?.match(/filename="([^"]+)"/) + if (match?.[1]) filename = match[1] + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + } catch (error) { + alert(`Failed to export course: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setLoading(false) + } + } + + return ( + + ) +} diff --git a/LearningPlatform/components/admin/courses-list.tsx b/LearningPlatform/components/admin/courses-list.tsx index 24ef0c3..ad7fa9a 100644 --- a/LearningPlatform/components/admin/courses-list.tsx +++ b/LearningPlatform/components/admin/courses-list.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { studentGlassPill } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' import { Edit, Eye, Trash2, CheckCircle, Circle } from 'lucide-react' +import { CourseExportButton } from '@/components/admin/course-export-button' import { toggleCoursePublish, deleteCourse } from '@/app/(admin)/admin/actions' import { useRouter } from 'next/navigation' import { useState } from 'react' @@ -141,6 +142,12 @@ export function CoursesList({ courses }: { courses: Course[] }) { + + + ) +} diff --git a/LearningPlatform/docker-compose.yml b/LearningPlatform/docker-compose.yml index ed7f1ee..eb35395 100644 --- a/LearningPlatform/docker-compose.yml +++ b/LearningPlatform/docker-compose.yml @@ -44,6 +44,8 @@ services: - "3000:3000" volumes: - ./public/media:/app/public/media + # Host folder for admin import panel (read-only for app; no DB wipe) + - ./content-bundles:/app/content-bundles depends_on: postgres: condition: service_healthy diff --git a/LearningPlatform/documentation/CONTENT_IMPORTS.md b/LearningPlatform/documentation/CONTENT_IMPORTS.md index 92cc86b..032a541 100644 --- a/LearningPlatform/documentation/CONTENT_IMPORTS.md +++ b/LearningPlatform/documentation/CONTENT_IMPORTS.md @@ -220,6 +220,77 @@ module.exports = [ Run order remains: **tags → courses → modules → flashcards** so `courseId` / `moduleId` / `tagSlugs` resolve correctly. +## Share courses with friends (bundle + photos) + +Use a **content bundle** — one folder with courses, flashcards, tags, and **copied image files**. + +**On your machine** (Docker Postgres running, from `LearningPlatform/`): + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@localhost:5433/exam_prep_db?schema=public" +$env:PAYLOAD_DATABASE_URL="postgresql://postgres:postgres@localhost:5433/exam_prep_db?schema=payload" +npm run content:export:bundle -- --out ./content-bundles/share-with-friends +``` + +Zip `content-bundles/share-with-friends/` and send it (Git, Drive, USB). + +**Your friends** (fresh local Docker app, from `LearningPlatform/`): + +```powershell +npm run content:import:bundle -- --from ./content-bundles/share-with-friends +``` + +Bundle layout: + +| Path | Contents | +|------|----------| +| `tags/*.js` | Prisma tags | +| `courses/*.js` | Full course trees (lessons, tasks, theory) | +| `flashcards/*.js` | Decks + cards | +| `assets/*` | **Photo files** (covers, lesson images, task images, flashcard images) | +| `manifest.json` | Export metadata | +| `README.txt` | Import hint | + +Import copies `assets/` into `scripts/imports/assets/`, registers media in Payload, then runs **tags → courses → flashcards**. + +> Run export/import from the **host** (not `docker compose exec app`) — the production container cannot init Payload for scripts. + +--- + +## Export (backup / migrate) + +Export writes **the same shapes** as import (`.js` files with `module.exports = …`) under `scripts/imports/data-private/export-/` by default (gitignored). + +For sharing **with photos**, prefer `npm run content:export:bundle` (see above). + +| Script | Scope | +|--------|--------| +| `npm run content:export:all` | **tags → courses → flashcards** (recommended) | +| `npm run content:export:tags` | All Prisma tags (`{ name, slug, main? }`) | +| `npm run content:export:course` | One `.js` per Payload course (`{ subject, course, modules }`) | +| `npm run content:export:flashcards` | Deck files (`{ deck, cards }` or chunked subdecks) | + +Flags: `--out `, `--dry-run`, `--used-tags-only` (tags runner / export-all only). + +**Re-import after export** + +1. Copy `export-…/tags/*.js` → `scripts/imports/data-private/tags/` (or `data/tags/`) +2. Copy `export-…/courses/*.js` → `data-private/courses/` +3. Copy `export-…/flashcards/*.js` → `data-private/flashcards/` +4. Run `npm run content:import:all` (same order as export: **tags first**, then courses, then flashcards) + +**Tags:** Yes — tasks and flashcards reference tags by **slug**. Export includes all tags by default; use `--used-tags-only` for a smaller tag file. + +**Media:** Theory images and course covers export as `__IMPORT_PLACEHOLDER_IMAGE__` or a basename under `scripts/imports/assets/` when possible. Put custom images in `assets/` before re-import, or replace in Admin → Media after import. + +**Docker** (from `LearningPlatform/`, stack running): + +```bash +docker compose exec app npm run content:export:all +``` + +Copy files out of the container if needed (`docker cp …`). + ## Commands | Script | Scope | diff --git a/DOCKER-HUB-RUN.md b/LearningPlatform/documentation/DOCKER-HUB-RUN.md similarity index 100% rename from DOCKER-HUB-RUN.md rename to LearningPlatform/documentation/DOCKER-HUB-RUN.md diff --git a/LearningPlatform/lib/content-bundle/server.ts b/LearningPlatform/lib/content-bundle/server.ts new file mode 100644 index 0000000..f3991d1 --- /dev/null +++ b/LearningPlatform/lib/content-bundle/server.ts @@ -0,0 +1,131 @@ +import { createRequire } from 'node:module' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' + +const require = createRequire(import.meta.url) +const { ZipArchive } = require('archiver') as typeof import('archiver') + +const APP_ROOT = process.cwd() +const { loadEnv } = require('../../scripts/imports/helpers/utils') +const { initPayloadClient } = require('../../scripts/imports/helpers/payload-client') +const { createPrismaClient } = require('../../scripts/imports/helpers/prisma-client') +const { exportSingleCourseBundle } = require('../../scripts/imports/helpers/bundle-export-course') +const { importBundleFromPath } = require('../../scripts/imports/runners/import-bundle') + +export const CONTENT_BUNDLES_INCOMING = path.join(APP_ROOT, 'content-bundles', 'incoming') + +export type IncomingBundleInfo = { + name: string + courseTitle?: string + courseSlug?: string + exportedAt?: string +} + +function ensureIncomingDir() { + fs.mkdirSync(CONTENT_BUNDLES_INCOMING, { recursive: true }) +} + +function zipDirectory(dir: string): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + const archive = new ZipArchive({ zlib: { level: 9 } }) + const passthrough = new PassThrough() + + passthrough.on('data', (chunk: Buffer) => chunks.push(chunk)) + passthrough.on('end', () => resolve(Buffer.concat(chunks))) + passthrough.on('error', reject) + archive.on('error', reject) + archive.pipe(passthrough) + archive.directory(dir, false) + void archive.finalize() + }) +} + +export async function exportCourseBundleZip( + courseId: string, +): Promise<{ buffer: Buffer; filename: string; title: string }> { + loadEnv(APP_ROOT) + if (!process.env.PAYLOAD_SECRET) { + throw new Error('PAYLOAD_SECRET is not configured') + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'brainstack-export-')) + + try { + const payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + const { prisma, disconnect } = createPrismaClient() + + try { + const result = await exportSingleCourseBundle({ + payload, + prisma, + courseId, + outRoot: tmpRoot, + }) + const buffer = await zipDirectory(tmpRoot) + const safeSlug = String(result.slug).replace(/[^\w.-]+/g, '_') + return { + buffer, + filename: `${safeSlug}-bundle.zip`, + title: result.title, + } + } finally { + await disconnect() + } + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + } +} + +export function listIncomingBundles(): IncomingBundleInfo[] { + ensureIncomingDir() + const entries = fs.readdirSync(CONTENT_BUNDLES_INCOMING, { withFileTypes: true }) + const bundles: IncomingBundleInfo[] = [] + + for (const ent of entries) { + if (!ent.isDirectory() || ent.name.startsWith('.')) continue + + const bundlePath = path.join(CONTENT_BUNDLES_INCOMING, ent.name) + const hasCourses = fs.existsSync(path.join(bundlePath, 'courses')) + const hasManifest = fs.existsSync(path.join(bundlePath, 'manifest.json')) + if (!hasCourses && !hasManifest) continue + + const info: IncomingBundleInfo = { name: ent.name } + if (hasManifest) { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(bundlePath, 'manifest.json'), 'utf8')) + if (manifest.courseTitle) info.courseTitle = String(manifest.courseTitle) + if (manifest.courseSlug) info.courseSlug = String(manifest.courseSlug) + if (manifest.exportedAt) info.exportedAt = String(manifest.exportedAt) + } catch { + /* ignore invalid manifest */ + } + } + bundles.push(info) + } + + return bundles.sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function importIncomingBundle(folderName: string) { + const safe = folderName.trim() + if (!/^[a-zA-Z0-9._-]+$/.test(safe)) { + throw new Error('Invalid bundle folder name') + } + + const bundlePath = path.join(CONTENT_BUNDLES_INCOMING, safe) + if (!fs.existsSync(bundlePath)) { + throw new Error(`Bundle folder not found: ${safe}`) + } + + const resolved = path.resolve(bundlePath) + const incomingResolved = path.resolve(CONTENT_BUNDLES_INCOMING) + if (resolved !== incomingResolved && !resolved.startsWith(`${incomingResolved}${path.sep}`)) { + throw new Error('Invalid bundle path') + } + + loadEnv(APP_ROOT) + return importBundleFromPath(bundlePath) as Promise<{ ok: boolean; assetsCopied: number }> +} diff --git a/LearningPlatform/lib/media-usage.ts b/LearningPlatform/lib/media-usage.ts new file mode 100644 index 0000000..7025c02 --- /dev/null +++ b/LearningPlatform/lib/media-usage.ts @@ -0,0 +1,210 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' +import { prisma } from '@/lib/prisma' + +export interface MediaUsage { + lessonsCount: number + tasksCount: number + coursesCount: number + flashcardsCount: number + lessonRefs: Array<{ id: number; title: string }> + taskRefs: Array<{ id: number; lessonId: number; lessonTitle: string }> + courseRefs: Array<{ id: number | string; title: string }> +} + +type LessonDoc = { + id: number + title: string + theoryBlocks?: Array> +} + +function emptyUsage(): MediaUsage { + return { + lessonsCount: 0, + tasksCount: 0, + coursesCount: 0, + flashcardsCount: 0, + lessonRefs: [], + taskRefs: [], + courseRefs: [], + } +} + +export function isMediaInUse(usage: MediaUsage): boolean { + return ( + usage.lessonsCount > 0 || + usage.tasksCount > 0 || + usage.coursesCount > 0 || + usage.flashcardsCount > 0 + ) +} + +export function mediaUsageTotal(usage: MediaUsage): number { + return usage.lessonsCount + usage.tasksCount + usage.coursesCount + usage.flashcardsCount +} + +function coverMediaIdFromCourse(course: Record): string { + const ci = course.coverImage + if (ci == null || ci === '') return '' + if (typeof ci === 'number' || typeof ci === 'string') return String(ci) + if (typeof ci === 'object' && ci !== null && 'id' in ci) return String((ci as { id: unknown }).id) + return '' +} + +const getCachedLessonsTasksCourses = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const [lessonsResult, tasksResult, coursesResult] = await Promise.all([ + payload.find({ collection: 'lessons', limit: 1000, depth: 0 }), + payload.find({ collection: 'tasks', limit: 5000, depth: 1 }), + payload.find({ collection: 'courses', limit: 500, depth: 0 }), + ]) + return { lessons: lessonsResult.docs, tasks: tasksResult.docs, courses: coursesResult.docs } + }, + ['admin-media-usage-data-v2'], + { revalidate: 30 }, +) + +async function getFlashcardMediaRefCounts(): Promise> { + const cards = await prisma.flashcard.findMany({ + where: { + OR: [{ questionImageId: { not: null } }, { answerImageId: { not: null } }], + }, + select: { questionImageId: true, answerImageId: true }, + }) + const counts = new Map() + for (const card of cards) { + if (card.questionImageId) { + counts.set(card.questionImageId, (counts.get(card.questionImageId) ?? 0) + 1) + } + if (card.answerImageId) { + counts.set(card.answerImageId, (counts.get(card.answerImageId) ?? 0) + 1) + } + } + return counts +} + +/** All media ids referenced by lessons, tasks, course covers, or flashcards. */ +export async function collectUsedMediaIds(): Promise> { + const used = new Set() + const [{ lessons, tasks, courses }, flashcardCounts] = await Promise.all([ + getCachedLessonsTasksCourses(), + getFlashcardMediaRefCounts(), + ]) + + for (const id of flashcardCounts.keys()) used.add(id) + + for (const lesson of lessons as LessonDoc[]) { + if (!lesson.theoryBlocks || !Array.isArray(lesson.theoryBlocks)) continue + for (const block of lesson.theoryBlocks) { + const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } + if (typedBlock.blockType !== 'image') continue + const imageId = + typeof typedBlock.image === 'number' + ? String(typedBlock.image) + : typeof typedBlock.image === 'string' + ? typedBlock.image + : String(typedBlock.image?.id ?? '') + if (imageId) used.add(imageId) + } + } + + for (const task of tasks) { + const taskItem = task as Record + const questionMediaId = String(taskItem.questionMedia ?? '') + const solutionMediaId = String(taskItem.solutionMedia ?? '') + if (questionMediaId) used.add(questionMediaId) + if (solutionMediaId) used.add(solutionMediaId) + } + + for (const course of courses as Array>) { + const coverId = coverMediaIdFromCourse(course) + if (coverId) used.add(coverId) + } + + return used +} + +/** Per-media usage details for the given ids (admin media library cards). */ +export async function getMediaUsageForIds( + mediaIds: Array, +): Promise> { + const usageMap = new Map() + for (const id of mediaIds) { + usageMap.set(String(id), emptyUsage()) + } + + const [{ lessons, tasks, courses }, flashcardCounts] = await Promise.all([ + getCachedLessonsTasksCourses(), + getFlashcardMediaRefCounts(), + ]) + + for (const lesson of lessons as LessonDoc[]) { + if (!lesson.theoryBlocks || !Array.isArray(lesson.theoryBlocks)) continue + for (const block of lesson.theoryBlocks) { + const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } + if (typedBlock.blockType !== 'image') continue + const imageId = + typeof typedBlock.image === 'number' + ? String(typedBlock.image) + : typeof typedBlock.image === 'string' + ? typedBlock.image + : String(typedBlock.image?.id ?? '') + const usage = usageMap.get(imageId) + if (!usage) continue + usage.lessonsCount++ + if (usage.lessonRefs.length < 3) { + usage.lessonRefs.push({ id: lesson.id as number, title: lesson.title }) + } + } + } + + for (const task of tasks) { + const taskItem = task as Record + const questionMediaId = String(taskItem.questionMedia ?? '') + const solutionMediaId = String(taskItem.solutionMedia ?? '') + const lessonValue = taskItem.lesson as { id?: number; title?: string } | number | undefined + + const taskRef = { + id: taskItem.id as number, + lessonId: typeof lessonValue === 'number' ? lessonValue : (lessonValue?.id ?? 0), + lessonTitle: typeof lessonValue === 'object' ? (lessonValue?.title ?? 'Untitled') : 'Untitled', + } + + if (questionMediaId && usageMap.has(questionMediaId)) { + const usage = usageMap.get(questionMediaId)! + usage.tasksCount++ + if (usage.taskRefs.length < 3) usage.taskRefs.push(taskRef) + } + + if (solutionMediaId && solutionMediaId !== questionMediaId && usageMap.has(solutionMediaId)) { + const usage = usageMap.get(solutionMediaId)! + usage.tasksCount++ + if (usage.taskRefs.length < 3) usage.taskRefs.push(taskRef) + } + } + + for (const course of courses as Array>) { + const coverId = coverMediaIdFromCourse(course) + if (!coverId || !usageMap.has(coverId)) continue + const usage = usageMap.get(coverId)! + usage.coursesCount++ + if (usage.courseRefs.length < 3) { + usage.courseRefs.push({ + id: course.id as number | string, + title: String(course.title ?? 'Course'), + }) + } + } + + for (const mediaId of mediaIds) { + const key = String(mediaId) + const count = flashcardCounts.get(key) + if (!count) continue + const usage = usageMap.get(key) + if (usage) usage.flashcardsCount = count + } + + return usageMap +} diff --git a/LearningPlatform/package-lock.json b/LearningPlatform/package-lock.json index 588fc27..fea9b80 100644 --- a/LearningPlatform/package-lock.json +++ b/LearningPlatform/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@types/katex": "^0.16.8", "@types/pg": "^8.16.0", + "archiver": "^8.0.0", "badge-maker": "^5.0.2", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", @@ -53,6 +54,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/archiver": "^8.0.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", @@ -75,7 +77,7 @@ "vitest": "4.1.5" }, "engines": { - "node": "20.x", + "node": ">=20.9.0 <21", "npm": ">=10.0.0" } }, @@ -7421,6 +7423,17 @@ "@types/estree": "*" } }, + "node_modules/@types/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/readdir-glob": "*" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -7660,6 +7673,16 @@ "@types/react": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8634,6 +8657,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -8801,6 +8836,26 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -9032,6 +9087,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -9097,6 +9158,20 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -9145,6 +9220,118 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", @@ -9287,6 +9474,39 @@ "integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==", "license": "Apache-2.0" }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9629,6 +9849,22 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -9659,6 +9895,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -9684,6 +9926,31 @@ "node": ">= 6" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -11578,16 +11845,33 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -11646,6 +11930,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -12544,6 +12834,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -12982,6 +13278,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -13475,6 +13783,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -16288,6 +16644,21 @@ "node": ">=6" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -16670,6 +17041,73 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -16984,6 +17422,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -17565,6 +18023,26 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.27.0.tgz", + "integrity": "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -17872,6 +18350,27 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -17983,6 +18482,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -18774,6 +19282,12 @@ "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "license": "(WTFPL OR MIT)" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -19511,6 +20025,20 @@ "graphmatch": "^1.1.0" } }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/LearningPlatform/package.json b/LearningPlatform/package.json index daa26ba..70a86c2 100644 --- a/LearningPlatform/package.json +++ b/LearningPlatform/package.json @@ -42,7 +42,15 @@ "content:import:modules": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-modules.js", "content:import:flashcards": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-flashcards.js", "content:import:all": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-all.js", - "payload:cleanup-orphans": "tsx --tsconfig tsconfig.scripts.json ./scripts/cleanup-payload-orphans.ts" + "payload:cleanup-orphans": "tsx --tsconfig tsconfig.scripts.json ./scripts/cleanup-payload-orphans.ts", + "content:wipe": "tsx --tsconfig tsconfig.scripts.json ./scripts/wipe-content.ts", + "content:wipe:docker": "powershell -ExecutionPolicy Bypass -File ./scripts/wipe-content-docker.ps1", + "content:export:all": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-all.js", + "content:export:bundle": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-bundle.js", + "content:import:bundle": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-bundle.js", + "content:export:tags": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-tags.js", + "content:export:course": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-courses.js", + "content:export:flashcards": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-flashcards.js" }, "prisma": { "seed": "tsx --tsconfig tsconfig.scripts.json ./prisma/seed.ts" @@ -64,6 +72,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@types/katex": "^0.16.8", "@types/pg": "^8.16.0", + "archiver": "^8.0.0", "badge-maker": "^5.0.2", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", @@ -92,6 +101,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/archiver": "^8.0.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", diff --git a/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql b/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql new file mode 100644 index 0000000..3aaa70e --- /dev/null +++ b/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql @@ -0,0 +1,5 @@ +-- Delete flashcards when their deck is removed (matches Prisma onDelete: Cascade). +ALTER TABLE "flashcards" DROP CONSTRAINT "flashcards_deckId_fkey"; + +ALTER TABLE "flashcards" ADD CONSTRAINT "flashcards_deckId_fkey" + FOREIGN KEY ("deckId") REFERENCES "flashcard_decks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/LearningPlatform/prisma/schema.prisma b/LearningPlatform/prisma/schema.prisma index ee126dd..d09fc94 100644 --- a/LearningPlatform/prisma/schema.prisma +++ b/LearningPlatform/prisma/schema.prisma @@ -287,7 +287,7 @@ model Flashcard { /// ID of a Payload CMS media record (image). Null means no image attached. answerImageId String? deckId String - deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Restrict) + deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userProgress UserFlashcardProgress[] diff --git a/LearningPlatform/scripts/imports/helpers/bundle-export-course.js b/LearningPlatform/scripts/imports/helpers/bundle-export-course.js new file mode 100644 index 0000000..0f6f6a6 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/bundle-export-course.js @@ -0,0 +1,96 @@ +const path = require('path') +const { + loadMediaById, + safeBasename, + writeJsExport, + ensureDir, +} = require('./export-utils') +const { + MediaBundle, + getPublicMediaDir, + writeBundleManifest, + writeBundleReadme, +} = require('./bundle-media') +const { exportCourseStructure } = require('./course-export') +const { buildFlashcardExportFilesForCourse } = require('./flashcard-export') +const { exportTagsBySlugs } = require('./export-tags-helper') +const { + collectTagSlugsFromCourseStructure, + addTagSlugsFromFlashcardFiles, +} = require('./collect-tag-slugs') + +const APP_ROOT = path.join(__dirname, '../../..') + +/** + * Export one course (+ linked flashcard decks, tags, media) into a bundle folder. + */ +async function exportSingleCourseBundle({ payload, prisma, courseId, outRoot }) { + const course = await payload.findByID({ + collection: 'courses', + id: String(courseId), + overrideAccess: true, + }) + if (!course) { + throw new Error('Course not found') + } + + ensureDir(outRoot) + ensureDir(path.join(outRoot, 'courses')) + ensureDir(path.join(outRoot, 'tags')) + ensureDir(path.join(outRoot, 'flashcards')) + + const mediaById = await loadMediaById(payload) + const bundle = new MediaBundle({ + publicMediaDir: getPublicMediaDir(APP_ROOT), + assetsOutDir: path.join(outRoot, 'assets'), + mediaById, + }) + + const structure = await exportCourseStructure(payload, course, bundle) + const slug = safeBasename(structure.course.slug) + writeJsExport(path.join(outRoot, 'courses', `${slug}.js`), structure, { dryRun: false }) + + const tagSlugs = collectTagSlugsFromCourseStructure(structure) + const flashcardFiles = await buildFlashcardExportFilesForCourse(prisma, courseId, bundle) + addTagSlugsFromFlashcardFiles(flashcardFiles, tagSlugs) + + for (const file of flashcardFiles) { + writeJsExport( + path.join(outRoot, 'flashcards', `${safeBasename(file.basename)}.js`), + file.data, + { dryRun: false }, + ) + } + + await exportTagsBySlugs(prisma, [...tagSlugs], path.join(outRoot, 'tags', 'exported-tags.js')) + bundle.flush({ dryRun: false }) + + writeBundleManifest( + outRoot, + { + format: 'brainstack-content-bundle', + version: 1, + exportedAt: new Date().toISOString(), + courseId: String(courseId), + courseSlug: structure.course.slug, + courseTitle: structure.course.title, + mediaFiles: bundle.copied, + mediaMissing: bundle.missing.length, + flashcardFiles: flashcardFiles.length, + }, + { dryRun: false }, + ) + writeBundleReadme(outRoot, { dryRun: false }) + + return { + slug: structure.course.slug, + title: structure.course.title, + dir: outRoot, + mediaFiles: bundle.copied, + } +} + +module.exports = { + exportSingleCourseBundle, + APP_ROOT, +} diff --git a/LearningPlatform/scripts/imports/helpers/bundle-media.js b/LearningPlatform/scripts/imports/helpers/bundle-media.js new file mode 100644 index 0000000..a56fd77 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/bundle-media.js @@ -0,0 +1,143 @@ +const fs = require('fs') +const path = require('path') +const { + IMPORT_PLACEHOLDER_IMAGE_TOKEN, + PLACEHOLDER_MEDIA_FILENAME, + ensureDir, +} = require('./export-utils') + +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|pdf)$/i + +function safeExportBasename(filename, mediaId) { + const base = path.basename(String(filename || '').trim()) + if (!base) return `media-${String(mediaId).slice(0, 8)}.bin` + return base.replace(/[^\w.\-()+]/g, '_') +} + +/** + * Tracks media files to copy into a shareable bundle and maps ids → export basenames. + */ +class MediaBundle { + /** + * @param {{ publicMediaDir: string, assetsOutDir: string, mediaById: Map }} opts + */ + constructor(opts) { + this.publicMediaDir = opts.publicMediaDir + this.assetsOutDir = opts.assetsOutDir + this.mediaById = opts.mediaById + /** @type {Map} mediaId → export basename */ + this.idToExportName = new Map() + /** @type {Set} */ + this.usedExportNames = new Set() + this.missing = [] + this.copied = 0 + } + + /** + * @param {string|number|null|undefined} mediaId + * @returns {string|undefined} import reference: placeholder token or assets basename + */ + ref(mediaId) { + if (mediaId == null || mediaId === '') return undefined + const key = String(mediaId) + if (this.idToExportName.has(key)) { + return this.idToExportName.get(key) + } + + const row = this.mediaById.get(key) + const filename = row?.filename ? String(row.filename) : '' + if (!filename) { + this.missing.push(key) + return IMPORT_PLACEHOLDER_IMAGE_TOKEN + } + if (filename === PLACEHOLDER_MEDIA_FILENAME) { + return IMPORT_PLACEHOLDER_IMAGE_TOKEN + } + + let exportName = safeExportBasename(filename, key) + if (this.usedExportNames.has(exportName)) { + const ext = path.extname(exportName) + const stem = path.basename(exportName, ext) + exportName = `${stem}-${key.slice(0, 8)}${ext || ''}` + } + this.usedExportNames.add(exportName) + this.idToExportName.set(key, exportName) + return exportName + } + + flush({ dryRun }) { + if (!dryRun) ensureDir(this.assetsOutDir) + + for (const [mediaId, exportName] of this.idToExportName) { + const row = this.mediaById.get(mediaId) + const sourceName = row?.filename ? String(row.filename) : '' + if (!sourceName) continue + + const src = path.join(this.publicMediaDir, path.basename(sourceName)) + const dest = path.join(this.assetsOutDir, exportName) + + if (!fs.existsSync(src)) { + console.warn(`[WARN] Media file missing on disk: ${src} (id=${mediaId})`) + this.missing.push(mediaId) + continue + } + + if (dryRun) { + console.log(`[DRY-RUN] Would copy media ${sourceName} → assets/${exportName}`) + continue + } + + fs.copyFileSync(src, dest) + this.copied += 1 + console.log(`[COPY] assets/${exportName}`) + } + } +} + +function getPublicMediaDir(appRoot) { + return path.join(appRoot, 'public', 'media') +} + +function writeBundleManifest(outRoot, manifest, { dryRun }) { + const file = path.join(outRoot, 'manifest.json') + const body = `${JSON.stringify(manifest, null, 2)}\n` + if (dryRun) { + console.log(`[DRY-RUN] Would write ${file}`) + return + } + ensureDir(outRoot) + fs.writeFileSync(file, body, 'utf8') + console.log(`[WRITE] ${file}`) +} + +function writeBundleReadme(outRoot, { dryRun }) { + const file = path.join(outRoot, 'README.txt') + const body = `BrainStack content bundle +===================== + +Import on a fresh local install (from LearningPlatform/ folder, Docker Postgres running): + + npm run content:import:bundle -- --from path/to/this/folder + +Or manually: + 1. Copy assets/* → scripts/imports/assets/ + 2. Copy tags/, courses/, flashcards/ → scripts/imports/data-private/ + 3. CONTENT_IMPORT_SKIP_EXISTING_COURSES=0 npm run content:import:all + +Requires DATABASE_URL / PAYLOAD_DATABASE_URL and PAYLOAD_SECRET in .env. +` + if (dryRun) { + console.log(`[DRY-RUN] Would write ${file}`) + return + } + fs.writeFileSync(file, body, 'utf8') + console.log(`[WRITE] ${file}`) +} + +module.exports = { + MediaBundle, + getPublicMediaDir, + writeBundleManifest, + writeBundleReadme, + IMAGE_EXT, +} diff --git a/LearningPlatform/scripts/imports/helpers/collect-tag-slugs.js b/LearningPlatform/scripts/imports/helpers/collect-tag-slugs.js new file mode 100644 index 0000000..129a079 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/collect-tag-slugs.js @@ -0,0 +1,37 @@ +/** Collect unique tag slugs referenced in an exported course structure. */ +function collectTagSlugsFromCourseStructure(structure) { + const slugs = new Set() + if (!structure?.modules) return slugs + for (const mod of structure.modules) { + for (const lesson of mod.lessons || []) { + for (const task of lesson.tasks || []) { + for (const slug of task.tagSlugs || []) { + if (slug) slugs.add(String(slug)) + } + } + } + } + return slugs +} + +function addTagSlugsFromFlashcardFiles(files, slugs) { + for (const file of files) { + const data = file.data + const entries = Array.isArray(data) ? data : [data] + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue + const deck = entry.deck + if (deck?.tagSlugs) { + for (const s of deck.tagSlugs) if (s) slugs.add(String(s)) + } + for (const card of entry.cards || []) { + for (const s of card.tagSlugs || []) if (s) slugs.add(String(s)) + } + } + } +} + +module.exports = { + collectTagSlugsFromCourseStructure, + addTagSlugsFromFlashcardFiles, +} diff --git a/LearningPlatform/scripts/imports/helpers/course-export.js b/LearningPlatform/scripts/imports/helpers/course-export.js new file mode 100644 index 0000000..2dae677 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/course-export.js @@ -0,0 +1,257 @@ +const { lexicalToPlainText, omitUndefined } = require('./export-utils') + +const OA = { overrideAccess: true } + +async function findAll(payload, collection, where, sort) { + const docs = [] + let page = 1 + for (;;) { + const res = await payload.find({ + collection, + where, + sort, + limit: 100, + page, + depth: 2, + ...OA, + }) + docs.push(...res.docs) + if (!res.hasNextPage) break + page += 1 + } + return docs +} + +function mediaRef(bundle, mediaId) { + if (!bundle) return undefined + return bundle.ref(mediaId) +} + +function exportTheoryBlock(block, bundle) { + if (!block || typeof block !== 'object') return null + const type = block.blockType + if (!type) return null + + if (type === 'text' || type === 'callout') { + return omitUndefined({ + blockType: type, + ...(type === 'callout' && block.variant != null ? { variant: block.variant } : {}), + ...(type === 'callout' && block.title != null ? { title: block.title } : {}), + content: lexicalToPlainText(block.content), + }) + } + + if (type === 'image') { + const imageId = + typeof block.image === 'object' && block.image != null + ? block.image.id + : block.image + const image = mediaRef(bundle, imageId) + return omitUndefined({ + blockType: 'image', + image, + caption: block.caption ?? undefined, + align: block.align ?? undefined, + width: block.width ?? undefined, + }) + } + + if (type === 'video') { + return omitUndefined({ + blockType: 'video', + videoUrl: block.videoUrl ?? '', + title: block.title ?? undefined, + caption: block.caption ?? undefined, + aspectRatio: block.aspectRatio === '4:3' ? '4:3' : '16:9', + }) + } + + if (type === 'math') { + return omitUndefined({ + blockType: 'math', + latex: block.latex ?? '', + displayMode: block.displayMode ?? undefined, + note: block.note ?? undefined, + }) + } + + if (type === 'table') { + return omitUndefined({ + blockType: 'table', + caption: block.caption ?? undefined, + hasHeaders: block.hasHeaders !== false, + headers: block.headers ?? undefined, + rows: block.rows ?? undefined, + }) + } + + return omitUndefined({ ...block, blockType: type }) +} + +function exportTask(task, bundle) { + const tagSlugs = Array.isArray(task.tags) + ? task.tags.map((t) => t?.slug).filter(Boolean) + : [] + + const questionMediaId = + typeof task.questionMedia === 'object' && task.questionMedia != null + ? task.questionMedia.id + : task.questionMedia + const solutionMediaId = + typeof task.solutionMedia === 'object' && task.solutionMedia != null + ? task.solutionMedia.id + : task.solutionMedia + + const out = omitUndefined({ + type: task.type, + order: task.order, + prompt: lexicalToPlainText(task.prompt), + tagSlugs, + points: typeof task.points === 'number' ? task.points : 1, + correctAnswer: + typeof task.correctAnswer === 'string' ? task.correctAnswer : undefined, + autoGrade: typeof task.autoGrade === 'boolean' ? task.autoGrade : undefined, + solution: task.solution ? lexicalToPlainText(task.solution) : undefined, + solutionVideoUrl: + typeof task.solutionVideoUrl === 'string' && task.solutionVideoUrl.trim() + ? task.solutionVideoUrl.trim() + : undefined, + questionMedia: mediaRef(bundle, questionMediaId), + solutionMedia: mediaRef(bundle, solutionMediaId), + isPublished: task.isPublished === false ? false : undefined, + }) + + if (Array.isArray(task.choices) && task.choices.length > 0) { + out.choices = task.choices.map((c) => { + if (c && typeof c === 'object' && typeof c.text === 'string') { + return { text: c.text } + } + return { text: String(c) } + }) + } + + return out +} + +function exportLesson(lesson, bundle) { + const blocks = Array.isArray(lesson.theoryBlocks) + ? lesson.theoryBlocks.map((b) => exportTheoryBlock(b, bundle)).filter(Boolean) + : [] + + return omitUndefined({ + title: lesson.title, + order: lesson.order, + theoryBlocks: blocks, + tasks: [], + isPublished: lesson.isPublished === false ? false : undefined, + }) +} + +async function exportCourseStructure(payload, courseDoc, bundle) { + const courseId = String(courseDoc.id) + + let subject = { name: 'General', slug: 'general' } + if (courseDoc.subject) { + if (typeof courseDoc.subject === 'object') { + subject = { + name: courseDoc.subject.name || subject.name, + slug: courseDoc.subject.slug || subject.slug, + } + } else { + const sub = await payload.findByID({ + collection: 'subjects', + id: courseDoc.subject, + ...OA, + }) + if (sub) { + subject = { name: sub.name, slug: sub.slug } + } + } + } + + const course = omitUndefined({ + title: courseDoc.title, + slug: courseDoc.slug, + description: lexicalToPlainText(courseDoc.description), + level: courseDoc.level || 'BEGINNER', + isPublished: courseDoc.isPublished === false ? false : undefined, + coverImage: (() => { + const cover = courseDoc.coverImage + const coverId = + typeof cover === 'object' && cover != null ? cover.id : cover + if (coverId == null) return undefined + return mediaRef(bundle, coverId) + })(), + }) + + const modules = await findAll( + payload, + 'modules', + { course: { equals: courseId } }, + 'order', + ) + + const exportedModules = [] + + for (const mod of modules) { + const moduleId = String(mod.id) + const lessons = await findAll( + payload, + 'lessons', + { + and: [{ course: { equals: courseId } }, { module: { equals: moduleId } }], + }, + 'order', + ) + + const exportedLessons = [] + for (const lesson of lessons) { + const lessonExport = exportLesson(lesson, bundle) + const tasks = await findAll( + payload, + 'tasks', + { lesson: { contains: String(lesson.id) } }, + 'order', + ) + lessonExport.tasks = tasks.map((t) => exportTask(t, bundle)) + exportedLessons.push(lessonExport) + } + + exportedModules.push( + omitUndefined({ + title: mod.title, + order: mod.order, + isPublished: mod.isPublished === false ? false : undefined, + lessons: exportedLessons, + }), + ) + } + + return { + subject, + course, + modules: exportedModules, + } +} + +async function exportAllCourses(payload) { + return findAll(payload, 'courses', {}, '-createdAt') +} + +async function exportAllCoursesToDir({ payload, outRoot, bundle, dryRun, safeBasename, writeJsExport }) { + const path = require('path') + const coursesDir = path.join(outRoot, 'courses') + const courses = await exportAllCourses(payload) + for (const courseDoc of courses) { + const structure = await exportCourseStructure(payload, courseDoc, bundle) + const file = path.join(coursesDir, `${safeBasename(structure.course.slug)}.js`) + writeJsExport(file, structure, { dryRun }) + } + return courses.length +} + +module.exports = { + exportCourseStructure, + exportAllCourses, + exportAllCoursesToDir, +} diff --git a/LearningPlatform/scripts/imports/helpers/course-import.js b/LearningPlatform/scripts/imports/helpers/course-import.js index 98177c8..258a312 100644 --- a/LearningPlatform/scripts/imports/helpers/course-import.js +++ b/LearningPlatform/scripts/imports/helpers/course-import.js @@ -478,7 +478,12 @@ async function findOrCreateTask(payload, prisma, lessonId, taskData, tagsCache, tagsCache.set(tagKey, taskTags) } + const { resolveImportMediaRef } = require('./media-import') const taskPayload = toTaskData(taskData, lessonId, tagsCache.get(tagKey)) + const questionMedia = await resolveImportMediaRef(payload, taskData.questionMedia, { dryRun }) + const solutionMedia = await resolveImportMediaRef(payload, taskData.solutionMedia, { dryRun }) + if (questionMedia !== undefined) taskPayload.questionMedia = questionMedia + if (solutionMedia !== undefined) taskPayload.solutionMedia = solutionMedia return syncCreateOrUpdate( payload, @@ -689,6 +694,7 @@ module.exports = { importModulesIntoCourse, findSingle, ensureMediaFromImportAsset, + ensureLessonPlaceholderMedia, IMPORT_PLACEHOLDER_IMAGE_TOKEN, PLACEHOLDER_MEDIA_FILENAME, } diff --git a/LearningPlatform/scripts/imports/helpers/export-tags-helper.js b/LearningPlatform/scripts/imports/helpers/export-tags-helper.js new file mode 100644 index 0000000..0282dab --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/export-tags-helper.js @@ -0,0 +1,28 @@ +const path = require('path') +const { writeJsExport, ensureDir } = require('./export-utils') + +async function exportTagsBySlugs(prisma, tagSlugs, filePath, { dryRun = false } = {}) { + const unique = [...new Set((tagSlugs || []).map((s) => String(s).trim()).filter(Boolean))].sort() + ensureDir(path.dirname(filePath)) + + const tags = + unique.length === 0 + ? [] + : await prisma.tag.findMany({ + where: { slug: { in: unique } }, + orderBy: { slug: 'asc' }, + }) + + const payload = tags.map((t) => ({ + name: t.name, + slug: t.slug, + ...(t.main ? { main: true } : {}), + })) + + writeJsExport(filePath, payload, { dryRun }) + return payload.length +} + +module.exports = { + exportTagsBySlugs, +} diff --git a/LearningPlatform/scripts/imports/helpers/export-utils.js b/LearningPlatform/scripts/imports/helpers/export-utils.js new file mode 100644 index 0000000..784b8fe --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/export-utils.js @@ -0,0 +1,180 @@ +const fs = require('fs') +const path = require('path') + +const PLACEHOLDER_MEDIA_FILENAME = 'lesson-theory-placeholder.svg' +const IMPORT_PLACEHOLDER_IMAGE_TOKEN = '__IMPORT_PLACEHOLDER_IMAGE__' + +function lexicalToPlainText(content) { + if (!content) return '' + if (typeof content === 'string') return content + + const texts = [] + + function walk(node) { + if (!node) return + if (node?.text) texts.push(String(node.text)) + if (Array.isArray(node?.children)) node.children.forEach(walk) + if (node?.root) walk(node.root) + } + + walk(content) + return texts.join(' ').trim() +} + +function parseExportArgs(argv) { + const args = argv || process.argv.slice(2) + const dryRun = args.includes('--dry-run') || process.env.IMPORT_DRY_RUN === '1' + const help = args.includes('--help') || args.includes('-h') + const usedTagsOnly = args.includes('--used-tags-only') + + let outDir = null + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--out' && args[i + 1]) { + outDir = args[i + 1] + i += 1 + } + } + + return { dryRun, help, usedTagsOnly, outDir } +} + +function printExportHelp(title) { + console.log(`${title} +Options: + --out Output directory (default: scripts/imports/data-private/export-) + --used-tags-only Export only tags referenced by tasks/flashcards (default: all tags) + --dry-run Log only, do not write files + --help Show this message + +Re-import order (same as import): + npm run content:import:tags + npm run content:import:course (copy exported courses/*.js into data/courses/ or data-private/courses/) + npm run content:import:flashcards + Or: npm run content:import:all +`) +} + +function defaultExportDir(appRoot) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + return path.join(appRoot, 'scripts/imports/data-private', `export-${stamp}`) +} + +function resolveExportDir(appRoot, outDir) { + const dir = outDir ? path.resolve(appRoot, outDir) : defaultExportDir(appRoot) + return dir +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +function safeBasename(slug) { + return String(slug) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'export' +} + +/** Write `module.exports = …` file (import-compatible). */ +function writeJsExport(filePath, data, { dryRun }) { + const body = `/**\n * Auto-exported — safe to re-import with content:import:* runners.\n */\nmodule.exports = ${JSON.stringify(data, null, 2)}\n` + if (dryRun) { + console.log(`[DRY-RUN] Would write ${filePath}`) + return + } + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, body, 'utf8') + console.log(`[WRITE] ${filePath}`) +} + +function omitUndefined(obj) { + if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) { + return obj + } + const out = {} + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue + out[k] = omitUndefined(v) + } + return out +} + +/** + * Map Payload media id → import token or assets basename. + * @param {Map} mediaById + */ +function mediaRefForImport(mediaId, mediaById) { + if (mediaId == null || mediaId === '') return undefined + const key = String(mediaId) + const row = mediaById.get(key) + const filename = row?.filename ? String(row.filename) : '' + if (!filename) return IMPORT_PLACEHOLDER_IMAGE_TOKEN + if (filename === PLACEHOLDER_MEDIA_FILENAME) { + return IMPORT_PLACEHOLDER_IMAGE_TOKEN + } + const assetPath = path.join(__dirname, '../assets', path.basename(filename)) + if (fs.existsSync(assetPath)) { + return path.basename(filename) + } + return IMPORT_PLACEHOLDER_IMAGE_TOKEN +} + +async function loadMediaById(payload) { + const map = new Map() + let page = 1 + for (;;) { + const res = await payload.find({ + collection: 'media', + limit: 200, + page, + depth: 0, + overrideAccess: true, + }) + for (const doc of res.docs) { + map.set(String(doc.id), { + filename: doc.filename, + mimeType: doc.mimeType, + }) + } + if (!res.hasNextPage) break + page += 1 + } + return map +} + +function defaultBundleDir(appRoot) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + return path.join(appRoot, 'content-bundles', `bundle-${stamp}`) +} + +function parseBundleArgs(argv) { + const base = parseExportArgs(argv) + const args = argv || process.argv.slice(2) + let fromDir = null + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--from' && args[i + 1]) { + fromDir = args[i + 1] + i += 1 + } + } + return { ...base, fromDir } +} + +module.exports = { + PLACEHOLDER_MEDIA_FILENAME, + IMPORT_PLACEHOLDER_IMAGE_TOKEN, + lexicalToPlainText, + parseExportArgs, + printExportHelp, + defaultExportDir, + resolveExportDir, + ensureDir, + safeBasename, + writeJsExport, + omitUndefined, + mediaRefForImport, + loadMediaById, + defaultBundleDir, + parseBundleArgs, +} diff --git a/LearningPlatform/scripts/imports/helpers/flashcard-export.js b/LearningPlatform/scripts/imports/helpers/flashcard-export.js new file mode 100644 index 0000000..761b9b9 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/flashcard-export.js @@ -0,0 +1,155 @@ +const { omitUndefined } = require('./export-utils') + +function deckSpecFromRow(deck, parentSlugById) { + return omitUndefined({ + slug: deck.slug, + name: deck.name, + description: deck.description ?? undefined, + tagSlugs: deck.tags.map((t) => t.slug), + courseId: deck.courseId ?? undefined, + moduleId: deck.moduleId ?? undefined, + parentDeckSlug: deck.parentDeckId ? parentSlugById.get(deck.parentDeckId) : undefined, + subjectId: deck.subjectId ?? undefined, + }) +} + +function cardFromRow(card, bundle) { + return omitUndefined({ + question: card.question, + answer: card.answer, + tagSlugs: card.tags.map((t) => t.slug), + questionImage: bundle ? bundle.ref(card.questionImageId) : undefined, + answerImage: bundle ? bundle.ref(card.answerImageId) : undefined, + }) +} + +function buildFlashcardFilesFromDecks(decks, cards, bundle = null) { + const cardsByDeck = new Map() + for (const card of cards) { + if (!cardsByDeck.has(card.deckId)) cardsByDeck.set(card.deckId, []) + cardsByDeck.get(card.deckId).push(card) + } + + const parentSlugById = new Map(decks.map((d) => [d.id, d.slug])) + const mains = decks.filter((d) => !d.parentDeckId) + const childrenByParent = new Map() + for (const d of decks) { + if (!d.parentDeckId) continue + if (!childrenByParent.has(d.parentDeckId)) childrenByParent.set(d.parentDeckId, []) + childrenByParent.get(d.parentDeckId).push(d) + } + + const files = [] + + for (const main of mains) { + const children = childrenByParent.get(main.id) || [] + const mainCards = (cardsByDeck.get(main.id) || []).map((c) => cardFromRow(c, bundle)) + const base = main.slug + + if (main.courseId && children.length > 0) { + files.push({ + basename: `${base}-main-deck`, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: [], + }, + }) + const chunk = children.map((sub) => ({ + deck: deckSpecFromRow(sub, parentSlugById), + cards: (cardsByDeck.get(sub.id) || []).map((c) => cardFromRow(c, bundle)), + })) + files.push({ + basename: `${base}-subdecks`, + data: chunk, + }) + } else if (children.length > 0) { + files.push({ + basename: `${base}-main-deck`, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: mainCards, + }, + }) + const chunk = children.map((sub) => ({ + deck: deckSpecFromRow(sub, parentSlugById), + cards: (cardsByDeck.get(sub.id) || []).map((c) => cardFromRow(c, bundle)), + })) + files.push({ + basename: `${base}-subdecks`, + data: chunk, + }) + } else { + files.push({ + basename: base, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: mainCards, + }, + }) + } + } + + return files +} + +/** + * Build import-shaped flashcard file payloads grouped by main deck. + * @returns {Array<{ basename: string, data: unknown }>} + */ +async function buildFlashcardExportFiles(prisma, bundle = null) { + const decks = await prisma.flashcardDeck.findMany({ + include: { tags: true, parentDeck: { select: { slug: true } } }, + orderBy: [{ parentDeckId: 'asc' }, { slug: 'asc' }], + }) + + const cards = await prisma.flashcard.findMany({ + include: { tags: true }, + orderBy: { createdAt: 'asc' }, + }) + + return buildFlashcardFilesFromDecks(decks, cards, bundle) +} + +async function buildFlashcardExportFilesForCourse(prisma, courseId, bundle = null) { + const cid = String(courseId) + const decks = await prisma.flashcardDeck.findMany({ + where: { courseId: cid }, + include: { tags: true, parentDeck: { select: { slug: true } } }, + orderBy: [{ parentDeckId: 'asc' }, { slug: 'asc' }], + }) + if (decks.length === 0) return [] + + const deckIds = decks.map((d) => d.id) + const cards = await prisma.flashcard.findMany({ + where: { deckId: { in: deckIds } }, + include: { tags: true }, + orderBy: { createdAt: 'asc' }, + }) + + return buildFlashcardFilesFromDecks(decks, cards, bundle) +} + +/** Collect tag slugs used on decks/cards (for --used-tags-only). */ +async function collectUsedTagSlugs(prisma) { + const slugs = new Set() + const deckTags = await prisma.flashcardDeck.findMany({ + include: { tags: { select: { slug: true } } }, + }) + const cardTags = await prisma.flashcard.findMany({ + include: { tags: { select: { slug: true } } }, + }) + for (const d of deckTags) { + for (const t of d.tags) slugs.add(t.slug) + } + for (const c of cardTags) { + for (const t of c.tags) slugs.add(t.slug) + } + return slugs +} + +module.exports = { + buildFlashcardExportFiles, + buildFlashcardExportFilesForCourse, + buildFlashcardFilesFromDecks, + collectUsedTagSlugs, +} diff --git a/LearningPlatform/scripts/imports/helpers/flashcard-import.js b/LearningPlatform/scripts/imports/helpers/flashcard-import.js index 8883655..79423bc 100644 --- a/LearningPlatform/scripts/imports/helpers/flashcard-import.js +++ b/LearningPlatform/scripts/imports/helpers/flashcard-import.js @@ -30,10 +30,16 @@ async function findFlashcardByQuestionTagsAndDeck(prisma, question, desiredTagId return matches[0] || null } -function needsFlashcardUpdate(existing, answer, desiredTagIds) { +function needsFlashcardUpdate(existing, answer, desiredTagIds, questionImageId, answerImageId) { if (existing.answer !== answer) { return true } + if ((existing.questionImageId ?? null) !== (questionImageId ?? null)) { + return true + } + if ((existing.answerImageId ?? null) !== (answerImageId ?? null)) { + return true + } const currentTagIds = existing.tags.map((t) => t.id) return !sameIdList(currentTagIds, desiredTagIds) } @@ -131,9 +137,9 @@ async function upsertFlashcardDeck(prisma, spec, { dryRun }) { /** * @param {import('@prisma/client').PrismaClient} prisma * @param {Array<{ question: string, answer: string, tagSlugs?: string[] }>} cards - * @param {{ dryRun: boolean, deckId: string }} opts + * @param {{ dryRun: boolean, deckId: string, payload?: import('payload').Payload }} opts */ -async function importFlashcardsFromList(prisma, cards, { dryRun, deckId }) { +async function importFlashcardsFromList(prisma, cards, { dryRun, deckId, payload }) { const stats = { created: 0, updated: 0, skipped: 0, errors: [] } if (!deckId) { @@ -155,6 +161,16 @@ async function importFlashcardsFromList(prisma, cards, { dryRun, deckId }) { const dedupeKey = flashcardDedupeKey(card.question, tagSlugs, deckId) const desiredTagIds = await getTagIdsBySlug(prisma, tagSlugs) + let questionImageId = null + let answerImageId = null + if (payload && (card.questionImage != null || card.answerImage != null)) { + const { resolveImportMediaRef } = require('./media-import') + const q = await resolveImportMediaRef(payload, card.questionImage, { dryRun }) + const a = await resolveImportMediaRef(payload, card.answerImage, { dryRun }) + questionImageId = q != null ? String(q) : null + answerImageId = a != null ? String(a) : null + } + const uniqueSlugCount = new Set(tagSlugs.map((s) => String(s).trim()).filter(Boolean)).size if (uniqueSlugCount !== desiredTagIds.length) { console.warn( @@ -176,6 +192,8 @@ async function importFlashcardsFromList(prisma, cards, { dryRun, deckId }) { question: card.question, answer: card.answer, deckId, + questionImageId, + answerImageId, tags: { connect: desiredTagIds.map((id) => ({ id })) }, }, }) @@ -184,7 +202,7 @@ async function importFlashcardsFromList(prisma, cards, { dryRun, deckId }) { continue } - if (!needsFlashcardUpdate(existing, card.answer, desiredTagIds)) { + if (!needsFlashcardUpdate(existing, card.answer, desiredTagIds, questionImageId, answerImageId)) { console.log( `[SKIP] Flashcard exists: "${card.question.slice(0, 80)}${card.question.length > 80 ? '…' : ''}"`, ) @@ -202,6 +220,8 @@ async function importFlashcardsFromList(prisma, cards, { dryRun, deckId }) { where: { id: existing.id }, data: { answer: card.answer, + questionImageId, + answerImageId, tags: { set: desiredTagIds.map((id) => ({ id })) }, }, }) diff --git a/LearningPlatform/scripts/imports/helpers/media-import.js b/LearningPlatform/scripts/imports/helpers/media-import.js new file mode 100644 index 0000000..265f641 --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/media-import.js @@ -0,0 +1,35 @@ +const { + ensureMediaFromImportAsset, + IMPORT_PLACEHOLDER_IMAGE_TOKEN, +} = require('./course-import') + +/** + * Resolve import reference (basename or placeholder token) to Payload media id. + * @returns {Promise} undefined = omit field; null = clear + */ +async function resolveImportMediaRef(payload, raw, { dryRun }) { + if (raw === undefined) return undefined + if (raw === null) return null + if (dryRun) return 'dry-run-media-id' + + if (raw === IMPORT_PLACEHOLDER_IMAGE_TOKEN || raw === '__PLACEHOLDER__') { + const { ensureLessonPlaceholderMedia } = require('./course-import') + const doc = await ensureLessonPlaceholderMedia(payload) + return doc.id + } + + if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(String(raw).trim()))) { + return Number(raw) + } + + if (typeof raw === 'string' && raw.trim()) { + const doc = await ensureMediaFromImportAsset(payload, raw.trim()) + return doc.id + } + + return undefined +} + +module.exports = { + resolveImportMediaRef, +} diff --git a/LearningPlatform/scripts/imports/helpers/utils.js b/LearningPlatform/scripts/imports/helpers/utils.js index 1952d22..63b8a1b 100644 --- a/LearningPlatform/scripts/imports/helpers/utils.js +++ b/LearningPlatform/scripts/imports/helpers/utils.js @@ -55,6 +55,13 @@ function scanDataJsFiles(dataDir) { } function getImportDataDirs(appRoot, kind) { + const bundleRoot = process.env.CONTENT_IMPORT_BUNDLE_DIR + if (bundleRoot) { + const bundleDir = path.join(bundleRoot, kind) + if (fs.existsSync(bundleDir)) { + return [bundleDir] + } + } const dataDir = path.join(appRoot, 'scripts/imports/data', kind) const privateDir = path.join(appRoot, 'scripts/imports/data-private', kind) return [dataDir, privateDir] diff --git a/LearningPlatform/scripts/imports/runners/export-all.js b/LearningPlatform/scripts/imports/runners/export-all.js new file mode 100644 index 0000000..fab3d2b --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-all.js @@ -0,0 +1,60 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, ensureDir } = require('../helpers/export-utils') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-all.js — tags → courses → flashcards (import-compatible)') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + if (!opts.dryRun) { + ensureDir(outRoot) + } + console.log(`[INFO] export-all: ${outRoot}`) + + const sharedOut = ['--out', outRoot] + if (opts.dryRun) sharedOut.push('--dry-run') + if (opts.usedTagsOnly) sharedOut.push('--used-tags-only') + + const tags = require('./export-tags') + const courses = require('./export-courses') + const flashcards = require('./export-flashcards') + + const steps = [ + { name: 'tags', run: () => tags.run(sharedOut) }, + { name: 'courses', run: () => courses.run(sharedOut) }, + { name: 'flashcards', run: () => flashcards.run(sharedOut) }, + ] + + for (const step of steps) { + console.log(`\n========== [INFO] Export step: ${step.name} ==========`) + // eslint-disable-next-line no-await-in-loop + const code = await step.run() + if (code !== 0) { + console.error(`[ERROR] export-all: failed at "${step.name}"`) + return code + } + } + + console.log('\n[INFO] export-all complete') + console.log('[HINT] Re-import: copy files into scripts/imports/data-private/{tags,courses,flashcards}/ then:') + console.log(' npm run content:import:all') + return 0 +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR] export-all:', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-bundle.js b/LearningPlatform/scripts/imports/runners/export-bundle.js new file mode 100644 index 0000000..9e147d2 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-bundle.js @@ -0,0 +1,140 @@ +/** + * Export a shareable content bundle: tags, courses, flashcards, and image files. + * + * Usage (from LearningPlatform/, DB running): + * npm run content:export:bundle + * npm run content:export:bundle -- --out ./content-bundles/my-courses + * + * Docker + host (Payload must init — run from host, not production app container): + * $env:DATABASE_URL="postgresql://postgres:postgres@localhost:5433/exam_prep_db?schema=public" + * $env:PAYLOAD_DATABASE_URL="postgresql://postgres:postgres@localhost:5433/exam_prep_db?schema=payload" + * npm run content:export:bundle -- --out ./content-bundles/share-with-friends + */ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { + parseBundleArgs, + defaultBundleDir, + resolveExportDir, + ensureDir, + loadMediaById, + safeBasename, + writeJsExport, +} = require('../helpers/export-utils') +const { + MediaBundle, + getPublicMediaDir, + writeBundleManifest, + writeBundleReadme, +} = require('../helpers/bundle-media') +const { initPayloadClient } = require('../helpers/payload-client') +const { exportAllCoursesToDir } = require('../helpers/course-export') +const { buildFlashcardExportFiles } = require('../helpers/flashcard-export') +const { runExportTags } = require('./export-tags') +const { createPrismaClient } = require('../helpers/prisma-client') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseBundleArgs(argv) + if (opts.help) { + console.log(`export-bundle.js — shareable folder with courses, flashcards, tags, and photos + +Options: + --out Output folder (default: content-bundles/bundle-) + --dry-run Log only + --help + +Give friends the whole output folder + npm run content:import:bundle -- --from +`) + return 0 + } + + const outRoot = opts.outDir + ? path.resolve(APP_ROOT, opts.outDir) + : defaultBundleDir(APP_ROOT) + + if (!opts.dryRun) ensureDir(outRoot) + + const assetsDir = path.join(outRoot, 'assets') + const flashcardsDir = path.join(outRoot, 'flashcards') + + console.log(`[INFO] Export bundle → ${outRoot}`) + + let payload + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.error('[ERROR] Payload init failed:', err.message || err) + return 1 + } + + const mediaById = await loadMediaById(payload) + const bundle = new MediaBundle({ + publicMediaDir: getPublicMediaDir(APP_ROOT), + assetsOutDir: assetsDir, + mediaById, + }) + + const sharedOut = ['--out', outRoot] + if (opts.dryRun) sharedOut.push('--dry-run') + if (opts.usedTagsOnly) sharedOut.push('--used-tags-only') + + console.log('\n========== tags ==========') + const tagsCode = await runExportTags({ outRoot, opts, payload }) + if (tagsCode !== 0) return tagsCode + + console.log('\n========== courses ==========') + const courseCount = await exportAllCoursesToDir({ + payload, + outRoot, + bundle, + dryRun: opts.dryRun, + safeBasename, + writeJsExport, + }) + console.log(`[INFO] Exported ${courseCount} course file(s)`) + + console.log('\n========== flashcards ==========') + const { prisma, disconnect } = createPrismaClient() + try { + const files = await buildFlashcardExportFiles(prisma, bundle) + for (const { basename, data } of files) { + const file = path.join(flashcardsDir, `${safeBasename(basename)}.js`) + writeJsExport(file, data, { dryRun: opts.dryRun }) + } + console.log(`[INFO] Exported ${files.length} flashcard file(s)`) + } finally { + await disconnect() + } + + console.log('\n========== media assets ==========') + bundle.flush({ dryRun: opts.dryRun }) + + const manifest = { + format: 'brainstack-content-bundle', + version: 1, + exportedAt: new Date().toISOString(), + courses: courseCount, + mediaFiles: bundle.copied, + mediaMissing: bundle.missing.length, + } + writeBundleManifest(outRoot, manifest, { dryRun: opts.dryRun }) + writeBundleReadme(outRoot, { dryRun: opts.dryRun }) + + console.log('\n[INFO] Bundle ready. Zip this folder and share:') + console.log(` ${outRoot}`) + return 0 +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR] export-bundle:', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-courses.js b/LearningPlatform/scripts/imports/runners/export-courses.js new file mode 100644 index 0000000..403f9a0 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-courses.js @@ -0,0 +1,72 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { + parseExportArgs, + printExportHelp, + resolveExportDir, + loadMediaById, + safeBasename, + writeJsExport, +} = require('../helpers/export-utils') +const { MediaBundle, getPublicMediaDir } = require('../helpers/bundle-media') +const { initPayloadClient } = require('../helpers/payload-client') +const { exportAllCoursesToDir } = require('../helpers/course-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-courses.js — Payload courses → import-compatible .js files') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + console.log(`[INFO] Export directory: ${outRoot}`) + + let payload + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.error('[ERROR] Payload init failed:', err.message || err) + return 1 + } + + const mediaById = await loadMediaById(payload) + const assetsDir = path.join(outRoot, 'assets') + const bundle = new MediaBundle({ + publicMediaDir: getPublicMediaDir(APP_ROOT), + assetsOutDir: assetsDir, + mediaById, + }) + + const count = await exportAllCoursesToDir({ + payload, + outRoot, + bundle, + dryRun: opts.dryRun, + safeBasename, + writeJsExport, + }) + + if (count === 0) { + console.log('[INFO] No courses to export') + return 0 + } + + bundle.flush({ dryRun: opts.dryRun }) + console.log(`[INFO] Exported ${count} course(s), ${bundle.copied} media file(s)`) + return 0 +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-flashcards.js b/LearningPlatform/scripts/imports/runners/export-flashcards.js new file mode 100644 index 0000000..bceb826 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-flashcards.js @@ -0,0 +1,78 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { + parseExportArgs, + printExportHelp, + resolveExportDir, + writeJsExport, + safeBasename, + loadMediaById, +} = require('../helpers/export-utils') +const { MediaBundle, getPublicMediaDir } = require('../helpers/bundle-media') +const { initPayloadClient } = require('../helpers/payload-client') +const { createPrismaClient } = require('../helpers/prisma-client') +const { buildFlashcardExportFiles } = require('../helpers/flashcard-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-flashcards.js — Prisma flashcard decks → import-compatible .js files') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + const flashcardsDir = path.join(outRoot, 'flashcards') + console.log(`[INFO] Export directory: ${outRoot}`) + + let payload = null + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.warn('[WARN] Payload init failed — flashcard images may be omitted:', err.message || err) + } + + const mediaById = payload ? await loadMediaById(payload) : new Map() + const bundle = new MediaBundle({ + publicMediaDir: getPublicMediaDir(APP_ROOT), + assetsOutDir: path.join(outRoot, 'assets'), + mediaById, + }) + + const { prisma, disconnect } = createPrismaClient() + + try { + const files = await buildFlashcardExportFiles(prisma, bundle) + if (files.length === 0) { + console.log('[INFO] No flashcard decks to export') + await disconnect() + return 0 + } + + for (const { basename, data } of files) { + const file = path.join(flashcardsDir, `${safeBasename(basename)}.js`) + writeJsExport(file, data, { dryRun: opts.dryRun }) + } + + bundle.flush({ dryRun: opts.dryRun }) + console.log(`[INFO] Exported ${files.length} flashcard file(s), ${bundle.copied} media file(s)`) + await disconnect() + return 0 + } catch (err) { + await disconnect() + throw err + } +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-tags.js b/LearningPlatform/scripts/imports/runners/export-tags.js new file mode 100644 index 0000000..5416275 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-tags.js @@ -0,0 +1,99 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, writeJsExport } = require('../helpers/export-utils') +const { createPrismaClient } = require('../helpers/prisma-client') +const { initPayloadClient } = require('../helpers/payload-client') +const { collectUsedTagSlugs } = require('../helpers/flashcard-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function collectPayloadTaskTagSlugs(payload) { + const slugs = new Set() + let page = 1 + for (;;) { + const res = await payload.find({ + collection: 'tasks', + limit: 200, + page, + depth: 0, + overrideAccess: true, + }) + for (const task of res.docs) { + for (const t of task.tags || []) { + if (t?.slug) slugs.add(String(t.slug)) + } + } + if (!res.hasNextPage) break + page += 1 + } + return slugs +} + +async function runExportTags({ outRoot, opts, payload }) { + const { prisma, disconnect } = createPrismaClient() + try { + const tagsDir = path.join(outRoot, 'tags') + let tags + + if (opts.usedTagsOnly) { + const used = new Set([...(await collectUsedTagSlugs(prisma))]) + if (payload) { + for (const s of await collectPayloadTaskTagSlugs(payload)) used.add(s) + } + tags = await prisma.tag.findMany({ + where: { slug: { in: [...used] } }, + orderBy: { slug: 'asc' }, + }) + } else { + tags = await prisma.tag.findMany({ orderBy: { slug: 'asc' } }) + } + + const payload = tags.map((t) => ({ + name: t.name, + slug: t.slug, + ...(t.main ? { main: true } : {}), + })) + + writeJsExport(path.join(tagsDir, 'exported-tags.js'), payload, { dryRun: opts.dryRun }) + console.log(`[INFO] Exported ${payload.length} tag(s)`) + await disconnect() + return 0 + } catch (err) { + await disconnect() + throw err + } +} + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-tags.js — Prisma tags → import-compatible .js') + return 0 + } + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + console.log(`[INFO] Export directory: ${outRoot}`) + + let payload = null + if (opts.usedTagsOnly) { + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.error('[ERROR] Payload init failed (needed for --used-tags-only):', err.message || err) + return 1 + } + } + + return runExportTags({ outRoot, opts, payload }) +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run, runExportTags } diff --git a/LearningPlatform/scripts/imports/runners/import-bundle.js b/LearningPlatform/scripts/imports/runners/import-bundle.js new file mode 100644 index 0000000..5c9cc21 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/import-bundle.js @@ -0,0 +1,108 @@ +/** + * Import a shareable content bundle (from content:export:bundle). + * + * Usage: + * npm run content:import:bundle -- --from ./content-bundles/my-courses + */ +const fs = require('fs') +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseBundleArgs, ensureDir } = require('../helpers/export-utils') + +const APP_ROOT = path.join(__dirname, '../../..') + +function copyBundleAssets(bundleRoot, appRoot, { dryRun }) { + const srcDir = path.join(bundleRoot, 'assets') + const destDir = path.join(appRoot, 'scripts', 'imports', 'assets') + if (!fs.existsSync(srcDir)) { + console.log('[INFO] No assets/ in bundle — skipping media copy') + return 0 + } + const names = fs.readdirSync(srcDir).filter((n) => !n.startsWith('.')) + if (!dryRun) ensureDir(destDir) + let copied = 0 + for (const name of names) { + const src = path.join(srcDir, name) + if (!fs.statSync(src).isFile()) continue + const dest = path.join(destDir, name) + if (dryRun) { + console.log(`[DRY-RUN] Would copy assets/${name}`) + } else { + fs.copyFileSync(src, dest) + console.log(`[COPY] scripts/imports/assets/${name}`) + } + copied += 1 + } + return copied +} + +async function importBundleFromPath(bundleRoot, { dryRun = false } = {}) { + const resolved = path.resolve(bundleRoot) + if (!fs.existsSync(resolved)) { + throw new Error(`Bundle not found: ${resolved}`) + } + + const manifestPath = path.join(resolved, 'manifest.json') + if (fs.existsSync(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + console.log('[INFO] Bundle manifest:', manifest) + } catch { + /* ignore */ + } + } + + console.log(`[INFO] Import bundle from ${resolved}`) + const assetsCopied = copyBundleAssets(resolved, APP_ROOT, { dryRun }) + console.log(`[INFO] Media files staged: ${assetsCopied}`) + + if (dryRun) { + console.log('[DRY-RUN] Would run content:import:all from bundle dirs') + return { ok: true, assetsCopied } + } + + process.env.CONTENT_IMPORT_BUNDLE_DIR = resolved + process.env.CONTENT_IMPORT_SKIP_EXISTING_COURSES = '0' + + const importAll = require('./import-all') + const code = await importAll.run() + if (code !== 0) { + throw new Error('Import pipeline failed') + } + return { ok: true, assetsCopied } +} + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseBundleArgs(argv) + if (opts.help || !opts.fromDir) { + console.log(`import-bundle.js — import tags, courses, flashcards, and photos from a bundle folder + +Options: + --from Path to bundle folder (required) + --dry-run Log only + --help +`) + return opts.help ? 0 : 1 + } + + const bundleRoot = path.resolve(APP_ROOT, opts.fromDir) + try { + await importBundleFromPath(bundleRoot, { dryRun: opts.dryRun }) + return 0 + } catch (err) { + console.error(`[ERROR] ${err.message || err}`) + return 1 + } +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR] import-bundle:', err.message || err) + process.exit(1) + }) +} + +module.exports = { run, importBundleFromPath, copyBundleAssets, APP_ROOT } diff --git a/LearningPlatform/scripts/imports/runners/import-flashcards.js b/LearningPlatform/scripts/imports/runners/import-flashcards.js index 39b717f..340d157 100644 --- a/LearningPlatform/scripts/imports/runners/import-flashcards.js +++ b/LearningPlatform/scripts/imports/runners/import-flashcards.js @@ -9,6 +9,7 @@ const { getImportDataDirs, } = require('../helpers/utils') const { createPrismaClient } = require('../helpers/prisma-client') +const { initPayloadClient } = require('../helpers/payload-client') const { importFlashcardsFromList, upsertFlashcardDeck } = require('../helpers/flashcard-import') const APP_ROOT = path.join(__dirname, '../../..') @@ -111,6 +112,13 @@ async function run() { return 0 } + let payload = null + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.warn('[WARN] Payload init failed — flashcard images will be skipped:', err.message || err) + } + let hadError = false try { @@ -147,7 +155,11 @@ async function run() { try { const prefix = label ? `[${label}] ` : '' const { id: deckId } = await upsertFlashcardDeck(prisma, deckSpec, { dryRun: opts.dryRun }) - const result = await importFlashcardsFromList(prisma, cards, { dryRun: opts.dryRun, deckId }) + const result = await importFlashcardsFromList(prisma, cards, { + dryRun: opts.dryRun, + deckId, + payload, + }) if (result.errors.length > 0) { hadError = true } diff --git a/LearningPlatform/scripts/wipe-content-docker.ps1 b/LearningPlatform/scripts/wipe-content-docker.ps1 new file mode 100644 index 0000000..e317883 --- /dev/null +++ b/LearningPlatform/scripts/wipe-content-docker.ps1 @@ -0,0 +1,21 @@ +# Wipe courses, modules, lessons, tasks, and flashcards in the running Docker Postgres. +# Usage (from LearningPlatform/): +# .\scripts\wipe-content-docker.ps1 + +$ErrorActionPreference = 'Stop' +$container = 'learningplatform-postgres-1' +$sqlFile = Join-Path $PSScriptRoot 'wipe-content.sql' + +if (-not (docker inspect $container 2>$null)) { + Write-Error "Container '$container' is not running. Start with: docker compose up -d" +} + +Write-Host '[INFO] This will DELETE all courses, modules, lessons, tasks, and flashcards.' +$confirm = Read-Host 'Type YES to continue' +if ($confirm -ne 'YES') { + Write-Host '[ABORTED]' + exit 1 +} + +Get-Content $sqlFile -Raw | docker exec -i $container psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 +Write-Host '[DONE] Content wiped. Refresh http://localhost:3000/admin' diff --git a/LearningPlatform/scripts/wipe-content.sql b/LearningPlatform/scripts/wipe-content.sql new file mode 100644 index 0000000..ee04d31 --- /dev/null +++ b/LearningPlatform/scripts/wipe-content.sql @@ -0,0 +1,59 @@ +-- Wipe all courses, modules, lessons, tasks (Payload CMS) and flashcards (Prisma). +-- Keeps: users, media, subjects, tags, admin accounts. +-- +-- Docker: +-- docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 < scripts/wipe-content.sql +-- Or from LearningPlatform/: +-- Get-Content scripts/wipe-content.sql | docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 + +BEGIN; + +-- Prisma: student progress + flashcards +TRUNCATE TABLE + public.task_attempt_tags, + public.task_attempts, + public.task_progress_tags, + public.task_progress, + public.lesson_progress, + public.course_progress, + public.user_flashcard_progress, + public.flashcards, + public.user_standalone_flashcard_decks, + public.flashcard_decks +RESTART IDENTITY CASCADE; + +-- Payload CMS: all tables except auth, media, subjects, migrations, preferences +DO $$ +DECLARE + r RECORD; + keep_tables text[] := ARRAY[ + 'payload_migrations', + 'payload_users', + 'media', + 'subjects', + 'payload_locked_documents', + 'payload_locked_documents_rels', + 'payload_preferences', + 'payload_preferences_rels' + ]; +BEGIN + FOR r IN + SELECT tablename + FROM pg_tables + WHERE schemaname = 'payload' + AND tablename <> ALL (keep_tables) + AND tablename NOT LIKE 'subjects%' + LOOP + EXECUTE format('TRUNCATE TABLE payload.%I RESTART IDENTITY CASCADE', r.tablename); + RAISE NOTICE 'Truncated payload.%', r.tablename; + END LOOP; +END $$; + +COMMIT; + +SELECT 'courses' AS item, COUNT(*)::text AS remaining FROM payload.courses +UNION ALL SELECT 'modules', COUNT(*)::text FROM payload.modules +UNION ALL SELECT 'lessons', COUNT(*)::text FROM payload.lessons +UNION ALL SELECT 'tasks', COUNT(*)::text FROM payload.tasks +UNION ALL SELECT 'flashcards', COUNT(*)::text FROM public.flashcards +UNION ALL SELECT 'flashcard_decks', COUNT(*)::text FROM public.flashcard_decks; diff --git a/LearningPlatform/scripts/wipe-content.ts b/LearningPlatform/scripts/wipe-content.ts new file mode 100644 index 0000000..527a7ed --- /dev/null +++ b/LearningPlatform/scripts/wipe-content.ts @@ -0,0 +1,118 @@ +/** + * Remove all CMS courses (modules, lessons, tasks cascade via Payload hooks) + * and all Prisma flashcard decks/cards/progress. + * + * Does NOT delete: users, media, subjects, tags, creative spaces. + * + * Usage (app root): + * npx tsx --tsconfig tsconfig.scripts.json ./scripts/wipe-content.ts --confirm + * + * Docker (recommended — Payload scripts often fail in the production image): + * Get-Content scripts/wipe-content.sql | docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 + * Or: npm run content:wipe:docker + */ +import 'dotenv/config' +import { getPayload } from 'payload' +import config from '../src/payload/payload.config.js' +import { prisma } from '../lib/prisma.js' + +const PAGE_SIZE = 100 + +type CollectionSlug = 'courses' | 'modules' | 'lessons' | 'tasks' + +async function deleteAllInCollection( + payload: Awaited>, + collection: CollectionSlug, +): Promise { + let deleted = 0 + for (;;) { + const res = await payload.find({ + collection, + limit: PAGE_SIZE, + page: 1, + depth: 0, + overrideAccess: true, + }) + if (res.docs.length === 0) break + for (const doc of res.docs) { + await payload.delete({ + collection, + id: doc.id, + overrideAccess: true, + }) + deleted += 1 + console.log(`[DELETE] ${collection} ${doc.id}`) + } + } + return deleted +} + +async function wipeFlashcards(): Promise<{ + progress: number + flashcards: number + enrollments: number + decks: number +}> { + const progress = ( + await prisma.userFlashcardProgress.deleteMany() + ).count + const flashcards = (await prisma.flashcard.deleteMany()).count + const enrollments = ( + await prisma.userStandaloneFlashcardDeck.deleteMany() + ).count + const decks = (await prisma.flashcardDeck.deleteMany()).count + return { progress, flashcards, enrollments, decks } +} + +async function wipeCourseProgress(): Promise { + return (await prisma.courseProgress.deleteMany()).count +} + +async function main() { + const confirmed = + process.argv.includes('--confirm') || + process.env.WIPE_CONTENT_CONFIRM === '1' + + if (!confirmed) { + console.error( + '[ERROR] Destructive wipe aborted. Re-run with --confirm or WIPE_CONTENT_CONFIRM=1', + ) + process.exit(1) + } + + if (!process.env.DATABASE_URL && !process.env.PAYLOAD_DATABASE_URL) { + console.error('[ERROR] Set DATABASE_URL or PAYLOAD_DATABASE_URL') + process.exit(1) + } + + console.log('[INFO] Wiping flashcards (Prisma)...') + const flashSummary = await wipeFlashcards() + console.log('[INFO] Flashcards removed:', flashSummary) + + console.log('[INFO] Wiping Payload CMS content (courses → modules → lessons → tasks)...') + const payload = await getPayload({ config }) + + const deletedCourses = await deleteAllInCollection(payload, 'courses') + const deletedModules = await deleteAllInCollection(payload, 'modules') + const deletedLessons = await deleteAllInCollection(payload, 'lessons') + const deletedTasks = await deleteAllInCollection(payload, 'tasks') + + console.log('[INFO] Wiping orphaned course progress (Prisma)...') + const deletedProgress = await wipeCourseProgress() + + console.log('\n[INFO] Wipe complete:', { + flashcards: flashSummary, + payload: { + courses: deletedCourses, + modules: deletedModules, + lessons: deletedLessons, + tasks: deletedTasks, + }, + courseProgress: deletedProgress, + }) +} + +main().catch((err) => { + console.error('[ERROR]', err) + process.exit(1) +}) diff --git a/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts b/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts index 45d8b07..bb08239 100644 --- a/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts +++ b/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts @@ -5,11 +5,14 @@ const mockPrisma = createMockPrisma() vi.mock('@/lib/prisma', () => ({ prisma: mockPrisma })) vi.mock('@/auth', () => ({ auth: vi.fn() })) +vi.mock('next/cache', () => ({ revalidateTag: vi.fn() })) const { GET: decksGet, POST: decksPost } = await import('@/app/api/flashcard-decks/route') const { getFlashcardDashboardSummary } = await import('@/lib/flashcards-dashboard-summary') const { auth } = await import('@/auth') +const { revalidateTag } = await import('next/cache') const mockedAuth = vi.mocked(auth) +const mockedRevalidateTag = vi.mocked(revalidateTag) function adminSession() { mockedAuth.mockResolvedValue({ @@ -272,6 +275,7 @@ describe('flashcard-decks route', () => { expect(mockPrisma.flashcardDeck.delete).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'sub-1' } }), ) + expect(mockedRevalidateTag).toHaveBeenCalledWith('api-flashcards', 'max') }) it('DELETE /api/flashcard-decks/[id] cascades main deck, subdecks, and flashcards', async () => { @@ -296,6 +300,7 @@ describe('flashcard-decks route', () => { expect(mockPrisma.flashcardDeck.delete).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'main-1' } }), ) + expect(mockedRevalidateTag).toHaveBeenCalledWith('api-flashcards', 'max') }) })