From e9c4082f89f97adbe9061997e54a813f9441fe58 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sat, 21 Mar 2026 10:03:41 +0100 Subject: [PATCH] Improve MCP tooling and draft exercise publishing --- .../services/course-import/course-import.ts | 122 ++++++++++++++++++ apps/api/src/services/exercise/exercise.ts | 52 +++++++- .../automation/api/automation.svelte.ts | 11 ++ .../lib/features/automation/pages/mcp.svelte | 70 +++++++++- .../automation/utils/automation-utils.ts | 19 +++ .../src/lib/utils/translations/da.json | 30 ++++- .../src/lib/utils/translations/de.json | 30 ++++- .../src/lib/utils/translations/en.json | 72 ++++++++++- .../src/lib/utils/translations/es.json | 30 ++++- .../src/lib/utils/translations/fr.json | 30 ++++- .../src/lib/utils/translations/hi.json | 30 ++++- .../src/lib/utils/translations/pl.json | 30 ++++- .../src/lib/utils/translations/pt.json | 30 ++++- .../src/lib/utils/translations/ru.json | 30 ++++- .../src/lib/utils/translations/vi.json | 30 ++++- apps/docs/content/docs/mcp.mdx | 23 +++- packages/mcp/README.md | 21 ++- packages/mcp/package.json | 2 +- packages/mcp/src/config.ts | 2 +- .../validation/course-import/course-import.ts | 60 ++++++++- .../utils/src/validation/exercise/exercise.ts | 59 ++++++--- 21 files changed, 711 insertions(+), 72 deletions(-) diff --git a/apps/api/src/services/course-import/course-import.ts b/apps/api/src/services/course-import/course-import.ts index 51946c9f..3247981c 100644 --- a/apps/api/src/services/course-import/course-import.ts +++ b/apps/api/src/services/course-import/course-import.ts @@ -2,6 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { env } from '@api/config/env'; import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { createCourse, updateCourse } from '@api/services/course/course'; +import { createExercise, getExercise, listExercises, replaceExerciseService } from '@api/services/exercise'; import { createCourseSection, listCourseSections, updateCourseSectionService } from '@api/services/course/section'; import { createLesson, listLessons, updateLessonService } from '@api/services/lesson'; import { upsertLessonLanguageService, updateLessonLanguageService } from '@api/services/lesson-language'; @@ -22,6 +23,7 @@ import { type TCourseImportDraftPayload, type TCourseImportDraftPublish, type TCourseImportDraftPublishToCourse, + type TCourseImportDraftExercise, type TCourseImportDraftUpdate, ZCourseImportDraftPayload, ZCourseImportWarning @@ -36,6 +38,7 @@ import { eq } from 'drizzle-orm'; type DraftSummary = { sectionCount: number; lessonCount: number; + exerciseCount: number; localeCount: number; warningCount: number; }; @@ -52,22 +55,26 @@ type PublishDraftResult = { tagNames: string[]; createdSections: number; createdLessons: number; + createdExercises: number; localeCount: number; }; type PublishDraftToExistingCourseResult = PublishDraftResult & { updatedSections: number; updatedLessons: number; + updatedExercises: number; createdLessonLanguages: number; updatedLessonLanguages: number; untouchedSections: number; untouchedLessons: number; + untouchedExercises: number; }; function buildDraftSummary(draft: TCourseImportDraftPayload): DraftSummary { return { sectionCount: draft.sections.length, lessonCount: draft.lessons.length, + exerciseCount: draft.exercises?.length ?? 0, localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size, warningCount: draft.warnings.length }; @@ -241,6 +248,80 @@ function getPrimaryLessonContent( return lessonLanguages.find((lessonLanguage) => lessonLanguage.lessonExternalId === lessonExternalId)?.content; } +function buildExercisePublishPayload( + exercise: TCourseImportDraftExercise, + lessonIdMap: Map, + sectionIdMap: Map +) { + const lessonId = exercise.lessonExternalId ? lessonIdMap.get(exercise.lessonExternalId) : undefined; + if (exercise.lessonExternalId && !lessonId) { + throw new AppError(`Exercise "${exercise.title}" references an unknown lesson`, ErrorCodes.VALIDATION_ERROR, 400); + } + + const sectionId = exercise.sectionExternalId ? sectionIdMap.get(exercise.sectionExternalId) : undefined; + if (exercise.sectionExternalId && !sectionId) { + throw new AppError(`Exercise "${exercise.title}" references an unknown section`, ErrorCodes.VALIDATION_ERROR, 400); + } + + return { + title: exercise.title, + description: exercise.description, + lessonId: lessonId ?? '', + sectionId: sectionId ?? '', + order: exercise.order, + dueBy: exercise.dueBy, + questions: exercise.questions?.map((question) => ({ + question: question.question, + questionTypeId: question.questionTypeId, + points: question.points, + order: question.order, + settings: question.settings, + options: question.options?.map((option) => ({ + label: option.label, + isCorrect: option.isCorrect, + settings: option.settings + })) + })) + }; +} + +async function buildDraftExercisesSnapshot(courseId: string): Promise { + const exercises = await listExercises(courseId); + if (exercises.length === 0) { + return []; + } + + const detailedExercises = await Promise.all(exercises.map((exercise) => getExercise(exercise.id))); + + return detailedExercises.map((exercise) => ({ + externalId: exercise.id, + lessonExternalId: exercise.lessonId ?? undefined, + sectionExternalId: exercise.lessonId ? undefined : (exercise.sectionId ?? undefined), + title: exercise.title, + description: exercise.description ?? undefined, + order: exercise.order ?? undefined, + dueBy: exercise.dueBy ?? undefined, + questions: + exercise.questions && exercise.questions.length > 0 + ? exercise.questions.map((question) => ({ + question: question.title, + questionTypeId: question.questionTypeId, + points: question.points, + order: question.order, + settings: question.settings, + options: + question.options.length > 0 + ? question.options.map((option) => ({ + label: option.label ?? '', + isCorrect: option.isCorrect, + settings: option.settings + })) + : undefined + })) + : undefined + })); +} + async function buildCourseStructureSnapshot(orgId: string, courseId: string): Promise { const course = await getCourseRecord(orgId, courseId); const [sections, lessons, tags] = await Promise.all([ @@ -354,6 +435,8 @@ async function buildCourseStructureSnapshot(orgId: string, courseId: string): Pr ); } + const normalizedExercises = await buildDraftExercisesSnapshot(courseId); + const draft = ZCourseImportDraftPayload.parse({ course: { title: course.title, @@ -366,6 +449,7 @@ async function buildCourseStructureSnapshot(orgId: string, courseId: string): Pr sections: normalizedSections, lessons: normalizedLessons, lessonLanguages: normalizedLessonLanguages, + exercises: normalizedExercises, sourceReferences: [ { type: 'course', @@ -571,6 +655,7 @@ export async function publishCourseImportDraftService( tagNames: normalizeDraftTagNames(draft.tags), createdSections: 0, createdLessons: 0, + createdExercises: 0, localeCount: 0 }; } @@ -647,6 +732,15 @@ export async function publishCourseImportDraftService( }); } + let createdExercises = 0; + for (const exercise of draft.exercises ?? []) { + await createExercise({ + courseId, + ...buildExercisePublishPayload(exercise, lessonIdMap, sectionIdMap) + }); + createdExercises += 1; + } + const appliedTagNames = normalizeDraftTagNames(draft.tags); if (appliedTagNames.length > 0) { await assignCourseTagsByName(orgId, courseId, { @@ -674,6 +768,7 @@ export async function publishCourseImportDraftService( tagNames: appliedTagNames, createdSections: sectionIdMap.size, createdLessons: lessonIdMap.size, + createdExercises, localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size }; } catch (error) { @@ -718,8 +813,10 @@ export async function publishCourseImportDraftToExistingCourseService( listCourseSections(course.id), listLessons(course.id) ]); + const existingExercises = await listExercises(course.id); const existingSectionIds = new Set(existingSections.map((section) => section.id)); const existingLessonIds = new Set(existingLessons.map((lesson) => lesson.id)); + const existingExerciseIds = new Set(existingExercises.map((exercise) => exercise.id)); const existingLessonLanguages = await getLessonLanguagesByLessonIds(existingLessons.map((lesson) => lesson.id)); const sectionIdMap = new Map(); @@ -816,6 +913,25 @@ export async function publishCourseImportDraftToExistingCourseService( createdLessonLanguages += 1; } + let createdExercises = 0; + let updatedExercises = 0; + + for (const exercise of draft.exercises ?? []) { + const payload = buildExercisePublishPayload(exercise, lessonIdMap, sectionIdMap); + + if (existingExerciseIds.has(exercise.externalId)) { + await replaceExerciseService(exercise.externalId, payload); + updatedExercises += 1; + continue; + } + + await createExercise({ + courseId: course.id, + ...payload + }); + createdExercises += 1; + } + const appliedTagNames = normalizeDraftTagNames(draft.tags); if (appliedTagNames.length > 0) { await assignCourseTagsByName(orgId, course.id, { @@ -826,6 +942,9 @@ export async function publishCourseImportDraftToExistingCourseService( const referencedSectionIds = new Set([...sectionIdMap.values()].filter((id) => existingSectionIds.has(id))); const referencedLessonIds = new Set([...lessonIdMap.values()].filter((id) => existingLessonIds.has(id))); + const referencedExerciseIds = new Set( + (draft.exercises ?? []).map((exercise) => exercise.externalId).filter((id) => existingExerciseIds.has(id)) + ); const courseUrl = await resolveCourseUrl(orgId, course.id, courseTitle); await updateCourseImportDraft(orgId, draftId, { @@ -851,10 +970,13 @@ export async function publishCourseImportDraftToExistingCourseService( updatedSections, createdLessons, updatedLessons, + createdExercises, + updatedExercises, createdLessonLanguages, updatedLessonLanguages, untouchedSections: existingSections.length - referencedSectionIds.size, untouchedLessons: existingLessons.length - referencedLessonIds.size, + untouchedExercises: existingExercises.length - referencedExerciseIds.size, localeCount: new Set(draft.lessonLanguages.map((item) => item.locale)).size }; } catch (error) { diff --git a/apps/api/src/services/exercise/exercise.ts b/apps/api/src/services/exercise/exercise.ts index fc75a527..eca41a49 100644 --- a/apps/api/src/services/exercise/exercise.ts +++ b/apps/api/src/services/exercise/exercise.ts @@ -71,6 +71,7 @@ export async function createExercise(data: TExerciseCreate): Promise title: q.question, questionTypeId: q.questionTypeId || 1, points: q.points || 0, + order: typeof q.order === 'number' ? q.order : undefined, settings: q.settings ?? {} })); @@ -81,7 +82,7 @@ export async function createExercise(data: TExerciseCreate): Promise const question = questions[index]; if (!question) return; - const optionsData: TNewOption[] = q.options.map((opt) => ({ + const optionsData: TNewOption[] = (q.options ?? []).map((opt) => ({ questionId: question.id, label: opt.label, isCorrect: opt.isCorrect, @@ -233,6 +234,55 @@ export async function updateExerciseService(exerciseId: string, data: TExerciseU } } +/** + * Replaces an exercise's questions and options with the provided payload. + * Existing questions are deleted and recreated from the replacement payload. + */ +export async function replaceExerciseService(exerciseId: string, data: TExerciseUpdate): Promise { + try { + return await db.transaction(async (tx) => { + const txClient = tx as DbOrTxClient; + + const exerciseUpdate = buildExerciseUpdateFields(data); + if (Object.keys(exerciseUpdate).length > 0) { + const updated = await updateExercise(exerciseId, exerciseUpdate, txClient); + if (!updated) { + throw new AppError('Failed to update exercise', ErrorCodes.INTERNAL_ERROR, 500); + } + } + + const { questions: currentQuestions } = await fetchQuestionsAndOptions(exerciseId, txClient); + const currentQuestionIds = currentQuestions + .map((question) => question.id) + .filter((id): id is number => id !== undefined); + + if (currentQuestionIds.length > 0) { + await deleteQuestionsByIds(currentQuestionIds, txClient); + } + + const replacementQuestions = data.questions ?? []; + const hasReplacementOptions = replacementQuestions.some((question) => (question.options?.length ?? 0) > 0); + + if (replacementQuestions.length > 0) { + if (hasReplacementOptions) { + await syncOptionIdSequence(txClient); + } + + await createNewQuestionsWithOptions(exerciseId, replacementQuestions, txClient); + } + + return await getExercise(exerciseId, txClient); + }); + } catch (error) { + console.error('replaceExerciseService error:', error); + if (error instanceof AppError) { + throw error; + } + + throw new AppError('Failed to replace exercise', ErrorCodes.INTERNAL_ERROR, 500); + } +} + /** * Deletes an exercise (cascade deletes questions and options) * @param exerciseId Exercise ID diff --git a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts index 8b553d81..daab95b2 100644 --- a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts +++ b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts @@ -63,6 +63,17 @@ class AutomationApi extends BaseApiWithErrors { this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 }; } snackbar.success('snackbar.automation.created'); + }, + onError: (result) => { + if (typeof result === 'string') { + return; + } + + this.handleValidationError(result); + + if ('field' in result && result.field) { + this.errors[result.field] = result.error; + } } }); } diff --git a/apps/dashboard/src/lib/features/automation/pages/mcp.svelte b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte index 16088a8e..6cb3137c 100644 --- a/apps/dashboard/src/lib/features/automation/pages/mcp.svelte +++ b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte @@ -4,17 +4,21 @@ getClaudeCodeSnippet, getCodexSnippet, getCursorSnippet, + getOpenCodeSnippet, getMaskedAutomationSecret } from '$features/automation/utils/automation-utils'; import { isOrgAdmin } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; import * as Alert from '@cio/ui/base/alert'; import { Badge } from '@cio/ui/base/badge'; + import { Button } from '@cio/ui/base/button'; import * as Code from '@cio/ui/custom/code'; + import * as Dialog from '@cio/ui/base/dialog'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as Field from '@cio/ui/base/field'; import * as Table from '@cio/ui/base/table'; import * as Tabs from '@cio/ui/base/tabs'; + import { InputField } from '@cio/ui/custom/input-field'; import { IconButton } from '@cio/ui/custom/icon-button'; import CoinsIcon from '@lucide/svelte/icons/coins'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; @@ -27,11 +31,29 @@ let activeSetupTab = $state('cursor'); let generatedSecret = $state(null); + let isCreateKeyModalOpen = $state(false); + let keyLabel = $state(''); + + const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading); + + function resetCreateKeyModal() { + keyLabel = ''; + automationApi.resetErrors(); + } + + function openCreateKeyModal() { + resetCreateKeyModal(); + isCreateKeyModalOpen = true; + } async function onGenerateKey() { - await automationApi.createKey('mcp'); + const result = await automationApi.createKey('mcp', keyLabel); + if (!result) return; + generatedSecret = automationApi.generatedSecret; automationApi.clearGeneratedSecret(); + isCreateKeyModalOpen = false; + resetCreateKeyModal(); } async function onRevokeKey(keyId: string) { @@ -108,7 +130,7 @@ {#if $isOrgAdmin} @@ -209,6 +231,7 @@ {$t('automation.clients.cursor')} {$t('automation.clients.claude_code')} {$t('automation.clients.codex')} + {$t('automation.clients.opencode')} @@ -234,6 +257,49 @@ + + + + + + + + + + { + if (!isOpen) { + resetCreateKeyModal(); + } + }} +> + + + {$t('automation.mcp.keys.modal.title')} + {$t('automation.mcp.keys.modal.description')} + + +
+ +
+ + + + + +
+
diff --git a/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts b/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts index 6c13ba85..27952840 100644 --- a/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts +++ b/apps/dashboard/src/lib/features/automation/utils/automation-utils.ts @@ -30,6 +30,25 @@ export function getCodexSnippet(secret: string | null) { -- npx -y @classroomio/mcp`; } +export function getOpenCodeSnippet(secret: string | null) { + const apiKey = getAutomationSetupSecret(secret); + + return `{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "classroomio": { + "type": "local", + "command": ["npx", "-y", "@classroomio/mcp"], + "enabled": true, + "environment": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "${apiKey}" + } + } + } +}`; +} + export function getCursorSnippet(secret: string | null) { const apiKey = getAutomationSetupSecret(secret); diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 0f817b01..b1e681f9 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "elev1@example.com, elev2@example.com", "send_invite_emails": "Send invitationsmails med det samme", "create_email_invites": "Opret e-mail-invitationer", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Eksisterende invitationer", "refresh": "Opdater", "loading_invites": "Indlæser invitationer...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Invitationsrevision kunne ikke indlæses", "snackbar_metadata_copied": "Invitationsmetadata er kopieret", "snackbar_email_invites_success": "Oprettet {count} invitation(er). Sendt {sent}, mislykkedes {failed}. Sprang over {duplikater} dubletter.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Engangs (24 timer)", "multi_use_7d": "Multi-brug (7 dage)", @@ -2492,13 +2504,22 @@ "description": "Brug én nøgle med organisationsomfang pr. integration. Hvis en nøgle roteres eller tilbagekaldes, holder klienten op med at arbejde, indtil den nye hemmelighed er konfigureret.", "claude_code_description": "Registrerer `@classroomio/mcp` med Claude Code ved hjælp af den genererede org-omfangede nøgle.", "codex_description": "Tilføjer ClassroomIO MCP-serveren til Codex med den samme org-omfattede nøgle.", - "cursor_description": "Indsæt dette i `~/.cursor/mcp.json` eller dit arbejdsområde MCP-konfiguration." + "cursor_description": "Indsæt dette i `~/.cursor/mcp.json` eller dit arbejdsområde MCP-konfiguration.", + "opencode_description": "Tilføj dette til `opencode.json` i projektroden eller `~/.config/opencode/opencode.json`." }, "keys": { "title": "MCP nøgler", "description": "Disse nøgler autentificerer den aktuelle organisation, når en MCP-klient ringer til ClassroomIO gennem `@classroomio/mcp`.", "generate": "Generer MCP-nøgle", - "secret_once": "Denne hemmelighed vises én gang. Kopier det til din MCP-klient nu." + "secret_once": "Denne hemmelighed vises én gang. Kopier det til din MCP-klient nu.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Roter", "revoke": "Tilbagekald", "empty_title": "Ingen MCP-nøgler endnu", - "empty_description": "Opret den første org-omfattede MCP-nøgle til at forbinde Claude Code, Codex eller Cursor.", + "empty_description": "Opret den første org-omfattede MCP-nøgle til at forbinde Claude Code, Codex, Cursor eller OpenCode.", "created_at_label": "Oprettet:", "last_used_label": "Sidst brugt:", "never_used": "Aldrig", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Claude kode", "codex": "Codex", - "cursor": "Markør" + "cursor": "Markør", + "opencode": "OpenCode" }, "tabs": { "mcp": "MCP", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 784ce552..48666dc0 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "student1@example.com, student2@example.com", "send_invite_emails": "Senden Sie sofort Einladungs-E-Mails", "create_email_invites": "Erstellen Sie E-Mail-Einladungen", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Vorhandene Einladungen", "refresh": "Aktualisieren", "loading_invites": "Einladungen werden geladen...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Das Einladungsaudit konnte nicht geladen werden", "snackbar_metadata_copied": "Einladungsmetadaten kopiert", "snackbar_email_invites_success": "{count} Einladung(en) erstellt. Gesendet {sent}, fehlgeschlagen {failed}. {duplicates} Duplikat(e) übersprungen.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Einmalig (24h)", "multi_use_7d": "Mehrfachnutzung (7 Tage)", @@ -2492,13 +2504,22 @@ "description": "Verwenden Sie einen organisationsweiten Schlüssel pro Integration. Wenn ein Schlüssel rotiert oder widerrufen wird, funktioniert der Client nicht mehr, bis das neue Geheimnis konfiguriert ist.", "claude_code_description": "Registriert „@classroomio/mcp“ bei Claude Code unter Verwendung des generierten organisationsweit gültigen Schlüssels.", "codex_description": "Fügt den ClassroomIO MCP-Server mit demselben organisationsweit gültigen Schlüssel zu Codex hinzu.", - "cursor_description": "Fügen Sie dies in „~/.cursor/mcp.json“ oder Ihre Workspace-MCP-Konfiguration ein." + "cursor_description": "Fügen Sie dies in „~/.cursor/mcp.json“ oder Ihre Workspace-MCP-Konfiguration ein.", + "opencode_description": "Fügen Sie dies in `opencode.json` im Projektstamm oder in `~/.config/opencode/opencode.json` ein." }, "keys": { "title": "MCP-Schlüssel", "description": "Diese Schlüssel authentifizieren die aktuelle Organisation, wenn ein MCP-Client ClassroomIO über „@classroomio/mcp“ aufruft.", "generate": "MCP-Schlüssel generieren", - "secret_once": "Dieses Geheimnis wird einmal gezeigt. Kopieren Sie es jetzt in Ihren MCP-Client." + "secret_once": "Dieses Geheimnis wird einmal gezeigt. Kopieren Sie es jetzt in Ihren MCP-Client.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Drehen", "revoke": "Widerrufen", "empty_title": "Noch keine MCP-Schlüssel", - "empty_description": "Erstellen Sie den ersten organisationsweit gültigen MCP-Schlüssel, um Claude Code, Codex oder Cursor zu verbinden.", + "empty_description": "Erstellen Sie den ersten organisationsweit gültigen MCP-Schlüssel, um Claude Code, Codex, Cursor oder OpenCode zu verbinden.", "created_at_label": "Erstellt:", "last_used_label": "Zuletzt verwendet:", "never_used": "Niemals", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Claude Code", "codex": "Kodex", - "cursor": "Cursor" + "cursor": "Cursor", + "opencode": "OpenCode" }, "tabs": { "mcp": "MCP", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 253e7818..b25bbb06 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1074,6 +1074,9 @@ }, "download_pdf": "Download PDF", "autosaving": "Autosaving...", + "saving": "Saving…", + "saved": "Saved", + "draft_recovery": "An unsaved draft was found for this lesson. Would you like to restore it?", "done": "Done", "edit": "Edit", "mark_as": "Mark as", @@ -1652,7 +1655,7 @@ "select": "Select tutors in organization...", "to_add": "To add tutor to your organization,", "go_to": "go to organization settings", - "invite_students": "Invite Students", + "invite_students": "Add Students", "via_link": "You can invite students via an invite link", "via_qr": "You can invite students via QR", "download_qr": "Download QR code", @@ -1683,6 +1686,17 @@ "recipient_emails_placeholder": "student1@example.com, student2@example.com", "send_invite_emails": "Send invite emails immediately", "create_email_invites": "Create Email Invites", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Existing Invites", "refresh": "Refresh", "loading_invites": "Loading invites...", @@ -1715,7 +1729,8 @@ "snackbar_failed_revoke_invite": "Failed to revoke invite", "snackbar_failed_load_audit": "Failed to load invite audit", "snackbar_metadata_copied": "Invite metadata copied", - "snackbar_email_invites_success": "Created {count} invite(s). Sent {sent}, failed {failed}. Skipped {duplicates} duplicate(s)." + "snackbar_email_invites_success": "Created {count} invite(s). Sent {sent}, failed {failed}. Skipped {duplicates} duplicate(s).", + "select_at_least_one_student": "Select at least one existing student" }, "delete_confirmation": { "title": "Delete", @@ -1995,12 +2010,45 @@ "audience": { "title": "Audience", "export": "Export", + "import_users": "Import Users", "no_audience": "No audience to manage", "manage": "Manage all your students here and stay connected with them", "name": "Name", "email": "Email", "date_joined": "Date Joined", - "upgrade": "Upgrade your plan to invite more students" + "upgrade": "Upgrade your plan to invite more students", + "select_all": "Select all", + "selected_count": "{count} selected", + "assign_courses": "Assign to Courses", + "import": { + "title": "Import Users", + "description": "Add students to your organization by entering their email addresses. Optionally assign them to courses and send invitation emails.", + "emails_label": "Email Addresses", + "emails_placeholder": "Enter emails separated by commas, newlines, or semicolons\ne.g. alice@example.com, bob@example.com", + "course_access": "Course Access", + "no_courses": "No course assignment", + "all_courses": "All courses", + "select_courses": "Select specific courses", + "select_courses_placeholder": "Choose courses...", + "send_email": "Send invitation email to imported users", + "submit": "Import Users", + "back": "Back to Audience", + "snackbar_success": "Imported {imported} users ({skipped} already existed, {emailsSent} emails sent)", + "snackbar_no_emails": "Please enter at least one email address", + "snackbar_failed": "Failed to import users" + }, + "assign": { + "title": "Assign to Courses", + "description": "Give the selected users access to courses and optionally notify them by email.", + "select_courses": "Select courses", + "select_courses_placeholder": "Choose courses...", + "send_email": "Send notification email", + "submit": "Assign", + "cancel": "Cancel", + "snackbar_success": "Assigned {assigned} users to courses ({alreadyEnrolled} already enrolled, {emailsSent} emails sent)", + "snackbar_no_courses": "Please select at least one course", + "snackbar_failed": "Failed to assign courses" + } }, "analytics": { "title": "Analytics", @@ -2088,7 +2136,8 @@ "clients": { "claude_code": "Claude Code", "codex": "Codex", - "cursor": "Cursor" + "cursor": "Cursor", + "opencode": "OpenCode" }, "keys": { "active": "Active", @@ -2096,7 +2145,7 @@ "rotate": "Rotate", "revoke": "Revoke", "empty_title": "No MCP keys yet", - "empty_description": "Create the first org-scoped MCP key to connect Claude Code, Codex, or Cursor.", + "empty_description": "Create the first org-scoped MCP key to connect Claude Code, Codex, Cursor, or OpenCode.", "created_at_label": "Created:", "last_used_label": "Last used:", "never_used": "Never", @@ -2124,14 +2173,23 @@ "title": "MCP keys", "description": "These keys authenticate the current organization when an MCP client calls ClassroomIO through `@classroomio/mcp`.", "generate": "Generate MCP key", - "secret_once": "This secret is shown once. Copy it into your MCP client now." + "secret_once": "This secret is shown once. Copy it into your MCP client now.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } }, "setup": { "title": "Client setup", "description": "Use one org-scoped key per integration. If a key is rotated or revoked, the client stops working until the new secret is configured.", "claude_code_description": "Registers `@classroomio/mcp` with Claude Code using the generated org-scoped key.", "codex_description": "Adds the ClassroomIO MCP server to Codex with the same org-scoped key.", - "cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config." + "cursor_description": "Paste this into `~/.cursor/mcp.json` or your workspace MCP config.", + "opencode_description": "Add this to `opencode.json` in your project root or `~/.config/opencode/opencode.json`." } } }, diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index bfa38c91..f4388acc 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "estudiante1@ejemplo.com, estudiante2@ejemplo.com", "send_invite_emails": "Enviar correos electrónicos de invitación inmediatamente", "create_email_invites": "Crear invitaciones por correo electrónico", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Invitaciones existentes", "refresh": "Actualizar", "loading_invites": "Cargando invitaciones...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "No se pudo cargar la auditoría de invitación", "snackbar_metadata_copied": "Metadatos de invitación copiados", "snackbar_email_invites_success": "Se crearon {count} invitaciones. Enviado {enviado}, falló {falló}. Se omitieron {duplicados} duplicados.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Una vez (24h)", "multi_use_7d": "Multiuso (7 días)", @@ -2492,13 +2504,22 @@ "description": "Utilice una clave con ámbito de organización por integración. Si se rota o revoca una clave, el cliente deja de funcionar hasta que se configura el nuevo secreto.", "claude_code_description": "Registra `@classroomio/mcp` con Claude Code utilizando la clave de alcance organizacional generada.", "codex_description": "Agrega el servidor ClassroomIO MCP al Codex con la misma clave de ámbito de organización.", - "cursor_description": "Pegue esto en `~/.cursor/mcp.json` o en la configuración MCP de su espacio de trabajo." + "cursor_description": "Pegue esto en `~/.cursor/mcp.json` o en la configuración MCP de su espacio de trabajo.", + "opencode_description": "Agregue esto a `opencode.json` en la raíz del proyecto o a `~/.config/opencode/opencode.json`." }, "keys": { "title": "Claves MCP", "description": "Estas claves autentican la organización actual cuando un cliente MCP llama a ClassroomIO a través de `@classroomio/mcp`.", "generate": "Generar clave MCP", - "secret_once": "Este secreto se muestra una vez. Cópielo en su cliente MCP ahora." + "secret_once": "Este secreto se muestra una vez. Cópielo en su cliente MCP ahora.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "rotar", "revoke": "Revocar", "empty_title": "Aún no hay claves MCP", - "empty_description": "Cree la primera clave MCP con ámbito de organización para conectar Claude Code, Codex o Cursor.", + "empty_description": "Cree la primera clave MCP con ámbito de organización para conectar Claude Code, Codex, Cursor u OpenCode.", "created_at_label": "Creado:", "last_used_label": "Último usado:", "never_used": "nunca", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Código Claude", "codex": "Códice", - "cursor": "Cursor" + "cursor": "Cursor", + "opencode": "OpenCode" }, "tabs": { "mcp": "MCP", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 9cfdd517..ced17db3 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "étudiant1@exemple.com, étudiant2@exemple.com", "send_invite_emails": "Envoyer immédiatement des e-mails d'invitation", "create_email_invites": "Créer des invitations par e-mail", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Invitations existantes", "refresh": "Actualiser", "loading_invites": "Chargement des invitations...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Échec du chargement de l'audit d'invitation", "snackbar_metadata_copied": "Métadonnées d'invitation copiées", "snackbar_email_invites_success": "{count} invitation(s) créée(s). Envoyé {envoyé}, échec {échec}. {duplicates} doublon(s) ignoré(s).", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Une fois (24h)", "multi_use_7d": "Multi-usage (7 jours)", @@ -2492,13 +2504,22 @@ "description": "Utilisez une clé à l’échelle de l’organisation par intégration. Si une clé est alternée ou révoquée, le client cesse de fonctionner jusqu'à ce que le nouveau secret soit configuré.", "claude_code_description": "Enregistre `@classroomio/mcp` auprès de Claude Code à l'aide de la clé générée à l'échelle de l'organisation.", "codex_description": "Ajoute le serveur ClassroomIO MCP au Codex avec la même clé de portée organisationnelle.", - "cursor_description": "Collez-le dans `~/.cursor/mcp.json` ou dans la configuration MCP de votre espace de travail." + "cursor_description": "Collez-le dans `~/.cursor/mcp.json` ou dans la configuration MCP de votre espace de travail.", + "opencode_description": "Ajoutez ceci à `opencode.json` à la racine du projet ou à `~/.config/opencode/opencode.json`." }, "keys": { "title": "Clés MCP", "description": "Ces clés authentifient l'organisation actuelle lorsqu'un client MCP appelle ClassroomIO via `@classroomio/mcp`.", "generate": "Générer une clé MCP", - "secret_once": "Ce secret est montré une fois. Copiez-le dans votre client MCP maintenant." + "secret_once": "Ce secret est montré une fois. Copiez-le dans votre client MCP maintenant.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Rotation", "revoke": "Révoquer", "empty_title": "Pas encore de clés MCP", - "empty_description": "Créez la première clé MCP à l'échelle de l'organisation pour connecter Claude Code, Codex ou Cursor.", + "empty_description": "Créez la première clé MCP à l'échelle de l'organisation pour connecter Claude Code, Codex, Cursor ou OpenCode.", "created_at_label": "Créé :", "last_used_label": "Dernière utilisation :", "never_used": "Jamais", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Claude Code", "codex": "Codex", - "cursor": "Curseur" + "cursor": "Curseur", + "opencode": "OpenCode" }, "tabs": { "mcp": "PCM", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 4bc75739..dcd1226b 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "student1@example.com, student2@example.com", "send_invite_emails": "तुरंत आमंत्रण ईमेल भेजें", "create_email_invites": "ईमेल आमंत्रण बनाएँ", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "मौजूदा आमंत्रण", "refresh": "ताज़ा करें", "loading_invites": "आमंत्रण लोड हो रहे हैं...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "आमंत्रण ऑडिट लोड करने में विफल", "snackbar_metadata_copied": "आमंत्रण मेटाडेटा कॉपी किया गया", "snackbar_email_invites_success": "{गिनती} आमंत्रण बनाया गया। भेजा गया {भेजा गया}, असफल हुआ {असफल}। {डुप्लिकेट} डुप्लिकेट को छोड़ दिया गया।", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "एक बार (24 घंटे)", "multi_use_7d": "बहु-उपयोग (7 दिन)", @@ -2492,13 +2504,22 @@ "description": "प्रति एकीकरण एक ऑर्ग-स्कोप्ड कुंजी का उपयोग करें। यदि कोई कुंजी घुमाई जाती है या निरस्त की जाती है, तो क्लाइंट नया रहस्य कॉन्फ़िगर होने तक काम करना बंद कर देता है।", "claude_code_description": "उत्पन्न ऑर्ग-स्कोप्ड कुंजी का उपयोग करके क्लाउड कोड के साथ `@classroomio/mcp` को पंजीकृत करता है।", "codex_description": "समान ऑर्ग-स्कोप्ड कुंजी के साथ क्लासरूमआईओ एमसीपी सर्वर को कोडेक्स में जोड़ता है।", - "cursor_description": "इसे `~/.cursor/mcp.json` या अपने कार्यक्षेत्र MCP कॉन्फ़िगरेशन में पेस्ट करें।" + "cursor_description": "इसे `~/.cursor/mcp.json` या अपने कार्यक्षेत्र MCP कॉन्फ़िगरेशन में पेस्ट करें।", + "opencode_description": "इसे अपने प्रोजेक्ट रूट के `opencode.json` या `~/.config/opencode/opencode.json` में जोड़ें।" }, "keys": { "title": "एमसीपी कुंजी", "description": "जब कोई MCP क्लाइंट `@classroomio/mcp` के माध्यम से ClassroomIO को कॉल करता है तो ये कुंजियाँ वर्तमान संगठन को प्रमाणित करती हैं।", "generate": "MCP कुंजी जनरेट करें", - "secret_once": "यह रहस्य एक बार दिखाया गया है। अब इसे अपने MCP क्लाइंट में कॉपी करें।" + "secret_once": "यह रहस्य एक बार दिखाया गया है। अब इसे अपने MCP क्लाइंट में कॉपी करें।", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "घुमाएँ", "revoke": "निरस्त करें", "empty_title": "अभी तक कोई MCP कुंजी नहीं है", - "empty_description": "क्लाउड कोड, कोडेक्स, या कर्सर को कनेक्ट करने के लिए पहली ऑर्ग-स्कोप्ड एमसीपी कुंजी बनाएं।", + "empty_description": "क्लाउड कोड, कोडेक्स, कर्सर, या OpenCode को कनेक्ट करने के लिए पहली ऑर्ग-स्कोप्ड एमसीपी कुंजी बनाएं।", "created_at_label": "बनाया गया:", "last_used_label": "अंतिम बार उपयोग किया गया:", "never_used": "कभी नहीं", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "क्लाउड कोड", "codex": "कोडेक्स", - "cursor": "कर्सर" + "cursor": "कर्सर", + "opencode": "OpenCode" }, "tabs": { "mcp": "एम.सी.पी", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 0de57d7b..82b5cd5d 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "student1@example.com, student2@example.com", "send_invite_emails": "Natychmiast wysyłaj e-maile z zaproszeniami", "create_email_invites": "Utwórz zaproszenia e-mailowe", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Istniejące zaproszenia", "refresh": "Odśwież", "loading_invites": "Ładowanie zaproszeń...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Nie udało się wczytać audytu zaproszeń", "snackbar_metadata_copied": "Zaproś skopiowane metadane", "snackbar_email_invites_success": "Utworzono zaproszenia ({count}). Wysłano {wysłano}, nie udało się {nie udało się}. Pominięte duplikaty: {duplikaty}.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Jednorazowo (24h)", "multi_use_7d": "Wielokrotnego użytku (7 dni)", @@ -2492,13 +2504,22 @@ "description": "Użyj jednego klucza o zasięgu organizacji na integrację. Jeśli klucz zostanie obrócony lub unieważniony, klient przestaje działać do czasu skonfigurowania nowego klucza tajnego.", "claude_code_description": "Rejestruje `@classroomio/mcp` w Claude Code przy użyciu wygenerowanego klucza o zasięgu organizacji.", "codex_description": "Dodaje serwer ClassroomIO MCP do Codexu z tym samym kluczem o zasięgu organizacji.", - "cursor_description": "Wklej to do `~/.cursor/mcp.json` lub konfiguracji MCP w obszarze roboczym." + "cursor_description": "Wklej to do `~/.cursor/mcp.json` lub konfiguracji MCP w obszarze roboczym.", + "opencode_description": "Dodaj to do `opencode.json` w katalogu głównym projektu lub do `~/.config/opencode/opencode.json`." }, "keys": { "title": "Klucze MCP", "description": "Te klucze uwierzytelniają bieżącą organizację, gdy klient MCP wywołuje ClassroomIO poprzez `@classroomio/mcp`.", "generate": "Wygeneruj klucz MCP", - "secret_once": "Sekret ten zostaje pokazany raz. Skopiuj go teraz do swojego klienta MCP." + "secret_once": "Sekret ten zostaje pokazany raz. Skopiuj go teraz do swojego klienta MCP.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Obróć", "revoke": "Odwołaj", "empty_title": "Nie ma jeszcze kluczy MCP", - "empty_description": "Utwórz pierwszy klucz MCP o zasięgu organizacji, aby połączyć Claude Code, Codex lub Cursor.", + "empty_description": "Utwórz pierwszy klucz MCP o zasięgu organizacji, aby połączyć Claude Code, Codex, Cursor lub OpenCode.", "created_at_label": "Utworzono:", "last_used_label": "Ostatnio używane:", "never_used": "Nigdy", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Claude'a Koda", "codex": "Kodeks", - "cursor": "Kursor" + "cursor": "Kursor", + "opencode": "OpenCode" }, "tabs": { "mcp": "MCP", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index a70445a1..451e9362 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "aluno1@exemplo.com, aluno2@exemplo.com", "send_invite_emails": "Envie e-mails de convite imediatamente", "create_email_invites": "Crie convites por e-mail", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Convites existentes", "refresh": "Atualizar", "loading_invites": "Carregando convites...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Falha ao carregar auditoria de convite", "snackbar_metadata_copied": "Metadados do convite copiados", "snackbar_email_invites_success": "Foram criados {count} convite(s). Enviado {enviado}, falhou {falhou}. {duplicatas} duplicatas ignoradas.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Única (24h)", "multi_use_7d": "Multiuso (7 dias)", @@ -2492,13 +2504,22 @@ "description": "Use uma chave com escopo organizacional por integração. Se uma chave for rotacionada ou revogada, o cliente para de funcionar até que o novo segredo seja configurado.", "claude_code_description": "Registra `@classroomio/mcp` com Claude Code usando a chave gerada com escopo organizacional.", "codex_description": "Adiciona o servidor ClassroomIO MCP ao Codex com a mesma chave com escopo organizacional.", - "cursor_description": "Cole isso em `~/.cursor/mcp.json` ou na configuração do MCP do seu espaço de trabalho." + "cursor_description": "Cole isso em `~/.cursor/mcp.json` ou na configuração do MCP do seu espaço de trabalho.", + "opencode_description": "Adicione isso a `opencode.json` na raiz do projeto ou a `~/.config/opencode/opencode.json`." }, "keys": { "title": "Chaves MCP", "description": "Essas chaves autenticam a organização atual quando um cliente MCP chama ClassroomIO por meio de `@classroomio/mcp`.", "generate": "Gerar chave MCP", - "secret_once": "Este segredo é mostrado uma vez. Copie-o para o seu cliente MCP agora." + "secret_once": "Este segredo é mostrado uma vez. Copie-o para o seu cliente MCP agora.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Girar", "revoke": "Revogar", "empty_title": "Nenhuma chave MCP ainda", - "empty_description": "Crie a primeira chave MCP com escopo organizacional para conectar Claude Code, Codex ou Cursor.", + "empty_description": "Crie a primeira chave MCP com escopo organizacional para conectar Claude Code, Codex, Cursor ou OpenCode.", "created_at_label": "Criado:", "last_used_label": "Usado pela última vez:", "never_used": "Nunca", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Código Claude", "codex": "Códice", - "cursor": "Cursor" + "cursor": "Cursor", + "opencode": "OpenCode" }, "tabs": { "mcp": "PCM", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 865453e7..6f49bd03 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "студент1@example.com, студент2@example.com", "send_invite_emails": "Отправляйте приглашения по электронной почте немедленно", "create_email_invites": "Создание приглашений по электронной почте", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Существующие приглашения", "refresh": "Обновить", "loading_invites": "Загрузка приглашений...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Не удалось загрузить аудит приглашений.", "snackbar_metadata_copied": "Метаданные приглашения скопированы.", "snackbar_email_invites_success": "Создано приглашений: {count}. Отправлено {отправлено}, ошибка {failed}. Пропущено дубликатов: {duulates}.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Одноразовый (24 часа)", "multi_use_7d": "Многоразовое использование (7 дней)", @@ -2492,13 +2504,22 @@ "description": "Используйте один ключ на уровне организации для каждой интеграции. Если ключ заменен или отозван, клиент перестает работать до тех пор, пока не будет настроен новый секрет.", "claude_code_description": "Регистрирует `@classroomio/mcp` в Claude Code, используя сгенерированный ключ на уровне организации.", "codex_description": "Добавляет сервер ClassroomIO MCP в Кодекс с тем же ключом на уровне организации.", - "cursor_description": "Вставьте это в `~/.cursor/mcp.json` или в конфигурацию MCP вашей рабочей области." + "cursor_description": "Вставьте это в `~/.cursor/mcp.json` или в конфигурацию MCP вашей рабочей области.", + "opencode_description": "Добавьте это в `opencode.json` в корне проекта или в `~/.config/opencode/opencode.json`." }, "keys": { "title": "Ключи MCP", "description": "Эти ключи аутентифицируют текущую организацию, когда клиент MCP вызывает ClassroomIO через @classroomio/mcp.", "generate": "Создать ключ MCP", - "secret_once": "Этот секрет раскрывается один раз. Скопируйте его в свой клиент MCP сейчас." + "secret_once": "Этот секрет раскрывается один раз. Скопируйте его в свой клиент MCP сейчас.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Поворот", "revoke": "Отозвать", "empty_title": "Ключей MCP пока нет", - "empty_description": "Создайте первый ключ MCP на уровне организации для подключения Claude Code, Codex или Cursor.", + "empty_description": "Создайте первый ключ MCP на уровне организации для подключения Claude Code, Codex, Cursor или OpenCode.", "created_at_label": "Создано:", "last_used_label": "Последнее использование:", "never_used": "Никогда", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Клод Код", "codex": "Кодекс", - "cursor": "Курсор" + "cursor": "Курсор", + "opencode": "OpenCode" }, "tabs": { "mcp": "МКП", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 17232d88..9455a6e0 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -794,6 +794,17 @@ "recipient_emails_placeholder": "sinh viên1@example.com, sinh viên2@example.com", "send_invite_emails": "Gửi email mời ngay lập tức", "create_email_invites": "Tạo lời mời qua email", + "existing_students_title": "Add existing org students", + "existing_students_description": "Choose student members already in this organization and grant them direct access to this course.", + "existing_students_search": "Search organization students", + "loading_existing_students": "Loading organization students...", + "no_existing_students": "No organization students are available to add.", + "no_matching_existing_students": "No organization students match your search.", + "notify_existing_students": "Send course access email", + "grant_access": "Grant Access", + "invite_new_students_title": "Invite new students", + "invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.", + "invite_new_students": "Invite Students", "existing_invites": "Lời mời hiện có", "refresh": "Làm mới", "loading_invites": "Đang tải lời mời...", @@ -827,6 +838,7 @@ "snackbar_failed_load_audit": "Không tải được lời mời kiểm tra", "snackbar_metadata_copied": "Đã sao chép siêu dữ liệu lời mời", "snackbar_email_invites_success": "Đã tạo {count} lời mời. Đã gửi {đã gửi}, không thành công {không thành công}. Đã bỏ qua {trùng lặp} bản sao.", + "select_at_least_one_student": "Select at least one existing student", "presets": { "one_time_24h": "Một lần (24h)", "multi_use_7d": "Sử dụng nhiều lần (7 ngày)", @@ -2492,13 +2504,22 @@ "description": "Sử dụng một khóa trong phạm vi tổ chức cho mỗi lần tích hợp. Nếu một khóa bị xoay hoặc bị thu hồi, máy khách sẽ ngừng hoạt động cho đến khi bí mật mới được định cấu hình.", "claude_code_description": "Đăng ký `@classroomio/mcp` với Mã Claude bằng cách sử dụng khóa trong phạm vi tổ chức được tạo.", "codex_description": "Thêm máy chủ ClassroomIO MCP vào Codex bằng cùng một khóa trong phạm vi tổ chức.", - "cursor_description": "Dán cái này vào `~/.cursor/mcp.json` hoặc cấu hình MCP không gian làm việc của bạn." + "cursor_description": "Dán cái này vào `~/.cursor/mcp.json` hoặc cấu hình MCP không gian làm việc của bạn.", + "opencode_description": "Thêm nội dung này vào `opencode.json` ở thư mục gốc dự án hoặc `~/.config/opencode/opencode.json`." }, "keys": { "title": "Khóa MCP", "description": "Các khóa này xác thực tổ chức hiện tại khi máy khách MCP gọi ClassroomIO thông qua `@classroomio/mcp`.", "generate": "Tạo khóa MCP", - "secret_once": "Bí mật này được hiển thị một lần. Sao chép nó vào ứng dụng khách MCP của bạn ngay bây giờ." + "secret_once": "Bí mật này được hiển thị một lần. Sao chép nó vào ứng dụng khách MCP của bạn ngay bây giờ.", + "modal": { + "title": "Create MCP key", + "description": "Choose a name for this MCP key before it is generated.", + "name_label": "Key name", + "name_placeholder": "ClassroomIO OpenCode", + "cancel": "Cancel", + "save": "Create key" + } } }, "keys": { @@ -2507,7 +2528,7 @@ "rotate": "Xoay", "revoke": "Thu hồi", "empty_title": "Chưa có khóa MCP", - "empty_description": "Tạo khóa MCP trong phạm vi tổ chức đầu tiên để kết nối Mã Claude, Codex hoặc Con trỏ.", + "empty_description": "Tạo khóa MCP trong phạm vi tổ chức đầu tiên để kết nối Mã Claude, Codex, Con trỏ hoặc OpenCode.", "created_at_label": "Đã tạo:", "last_used_label": "Sử dụng lần cuối:", "never_used": "không bao giờ", @@ -2518,7 +2539,8 @@ "clients": { "claude_code": "Mã Claude", "codex": "Codex", - "cursor": "Con trỏ" + "cursor": "Con trỏ", + "opencode": "OpenCode" }, "tabs": { "mcp": "MCP", diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index bc0f4770..c5fc2f1a 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -1,6 +1,6 @@ --- title: MCP -description: Connect Claude Code, Codex, Cursor, and other MCP clients to ClassroomIO for draft-based course authoring. +description: Connect Claude Code, Codex, Cursor, OpenCode, and other MCP clients to ClassroomIO for draft-based course authoring. --- import { Callout } from 'fumadocs-ui/components/callout'; @@ -17,7 +17,7 @@ ClassroomIO API. Teacher / Admin | v -Claude Code / Codex / Cursor / other MCP client +Claude Code / Codex / Cursor / OpenCode / other MCP client | | 1. Reads prompt, PDF, or existing course goal | 2. Produces or edits normalized course JSON @@ -109,6 +109,25 @@ codex mcp add classroomio \ -- npx -y @classroomio/mcp ``` +### OpenCode + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "classroomio": { + "type": "local", + "command": ["npx", "-y", "@classroomio/mcp"], + "enabled": true, + "environment": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "" + } + } + } +} +``` + ### Cursor ```json diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3ddc134a..e7c4743b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -10,7 +10,7 @@ The package does not parse PDFs or generate course structures. The agent does th Teacher / Admin | v -Claude Code / Codex / Cursor / other MCP client +Claude Code / Codex / Cursor / OpenCode / other MCP client | | 1. Reads prompt, PDF, or existing course goal | 2. Produces or edits normalized course JSON @@ -123,6 +123,25 @@ codex mcp add classroomio \ -- npx -y @classroomio/mcp ``` +### OpenCode + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "classroomio": { + "type": "local", + "command": ["npx", "-y", "@classroomio/mcp"], + "enabled": true, + "environment": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "" + } + } + } +} +``` + ### Cursor ```json diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 20f8f686..5ef9154c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@classroomio/mcp", - "version": "0.0.8", + "version": "0.0.9", "private": false, "type": "module", "main": "./dist/index.js", diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts index 54686c3b..97481b45 100644 --- a/packages/mcp/src/config.ts +++ b/packages/mcp/src/config.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; const ZConfig = z.object({ CLASSROOMIO_API_URL: z.url().default('https://api.classroomio.com'), CLASSROOMIO_API_KEY: z.string().min(1), - CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.1') + CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.9') }); export type McpServerConfig = z.infer; diff --git a/packages/utils/src/validation/course-import/course-import.ts b/packages/utils/src/validation/course-import/course-import.ts index 93d9cb28..a69f8d2d 100644 --- a/packages/utils/src/validation/course-import/course-import.ts +++ b/packages/utils/src/validation/course-import/course-import.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import { ZCourseMetadata } from '../course/course'; +import { ZExerciseQuestionTypeId } from '../exercise/exercise'; const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']); const LESSON_BODY_HTML_DESCRIPTION = @@ -54,6 +55,35 @@ export const ZCourseImportDraftLessonLanguage = z.object({ }); export type TCourseImportDraftLessonLanguage = z.infer; +export const ZCourseImportDraftExerciseOption = z.object({ + label: z.string().min(1), + isCorrect: z.boolean(), + settings: z.record(z.string(), z.unknown()).optional() +}); +export type TCourseImportDraftExerciseOption = z.infer; + +export const ZCourseImportDraftExerciseQuestion = z.object({ + question: z.string().min(1), + questionTypeId: ZExerciseQuestionTypeId.optional(), + points: z.number().int().min(0).optional(), + order: z.number().int().min(0).optional(), + settings: z.record(z.string(), z.unknown()).optional(), + options: z.array(ZCourseImportDraftExerciseOption).optional() +}); +export type TCourseImportDraftExerciseQuestion = z.infer; + +export const ZCourseImportDraftExercise = z.object({ + externalId: z.string().min(1), + lessonExternalId: z.string().min(1).optional(), + sectionExternalId: z.string().min(1).optional(), + title: z.string().min(1), + description: z.string().optional(), + order: z.number().int().min(0).optional(), + dueBy: z.string().optional(), + questions: z.array(ZCourseImportDraftExerciseQuestion).optional() +}); +export type TCourseImportDraftExercise = z.infer; + export const ZCourseImportDraftPayload = z .object({ course: ZCourseImportDraftCourse, @@ -61,7 +91,7 @@ export const ZCourseImportDraftPayload = z sections: z.array(ZCourseImportDraftSection).min(1), lessons: z.array(ZCourseImportDraftLesson).min(1), lessonLanguages: z.array(ZCourseImportDraftLessonLanguage).min(1), - exercises: z.array(z.record(z.string(), z.unknown())).optional(), + exercises: z.array(ZCourseImportDraftExercise).optional(), sourceReferences: z.array(ZCourseImportSourceReference).optional(), warnings: z.array(ZCourseImportWarning).default([]) }) @@ -120,6 +150,34 @@ export const ZCourseImportDraftPayload = z }); } }); + + const exerciseIds = new Set(); + value.exercises?.forEach((exercise, index) => { + if (exerciseIds.has(exercise.externalId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exercises', index, 'externalId'], + message: 'Exercise externalId must be unique' + }); + } + exerciseIds.add(exercise.externalId); + + if (exercise.lessonExternalId && !lessonIds.has(exercise.lessonExternalId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exercises', index, 'lessonExternalId'], + message: 'Exercise lessonExternalId must reference an existing lesson' + }); + } + + if (exercise.sectionExternalId && !sectionIds.has(exercise.sectionExternalId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exercises', index, 'sectionExternalId'], + message: 'Exercise sectionExternalId must reference an existing section' + }); + } + }); }); export type TCourseImportDraftPayload = z.infer; diff --git a/packages/utils/src/validation/exercise/exercise.ts b/packages/utils/src/validation/exercise/exercise.ts index a06e445a..85a34349 100644 --- a/packages/utils/src/validation/exercise/exercise.ts +++ b/packages/utils/src/validation/exercise/exercise.ts @@ -2,6 +2,24 @@ import * as z from 'zod'; import { QUESTION_TYPE } from '../constants'; +const EXERCISE_QUESTION_TYPE_ID_LITERALS = [ + z.literal(QUESTION_TYPE.RADIO), + z.literal(QUESTION_TYPE.CHECKBOX), + z.literal(QUESTION_TYPE.TEXTAREA), + z.literal(QUESTION_TYPE.TRUE_FALSE), + z.literal(QUESTION_TYPE.SHORT_ANSWER), + z.literal(QUESTION_TYPE.NUMERIC), + z.literal(QUESTION_TYPE.FILL_BLANK), + z.literal(QUESTION_TYPE.FILE_UPLOAD), + z.literal(QUESTION_TYPE.MATCHING), + z.literal(QUESTION_TYPE.ORDERING), + z.literal(QUESTION_TYPE.HOTSPOT), + z.literal(QUESTION_TYPE.LINK) +] as const; + +export const ZExerciseQuestionTypeId = z.union(EXERCISE_QUESTION_TYPE_ID_LITERALS); +export type TExerciseQuestionTypeId = z.infer; + const QUESTION_VALIDATION_RULES: Record< number, Array<(question: { options?: Array<{ isCorrect: boolean }> }) => string | null> @@ -61,7 +79,7 @@ const QUESTION_VALIDATION_RULES: Record< const ZExerciseUpdateQuestionBase = z.object({ id: z.number().int().optional(), question: z.string().min(1), - questionTypeId: z.number().int().min(1).optional(), // Added to support question type updates + questionTypeId: ZExerciseQuestionTypeId.optional(), points: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(), settings: z.record(z.string(), z.unknown()).optional(), @@ -98,6 +116,25 @@ function validateQuestionOptions(question: z.infer;