Repository: rgerum/unofficial-duolingo-stories Branch: main Commit: 54942c64a17d Files: 429 Total size: 1.6 MB Directory structure: gitextract_re_01zim/ ├── .codex/ │ └── environments/ │ └── environment.toml ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── build.yml │ ├── build_pr.yml │ └── ci.yaml ├── .gitignore ├── AGENTS.md ├── CLAUDE.md ├── CONTEXT.md ├── README.md ├── biome.json ├── components.json ├── convex/ │ ├── _generated/ │ │ ├── ai/ │ │ │ ├── ai-files.state.json │ │ │ └── guidelines.md │ │ ├── api.d.ts │ │ ├── api.js │ │ ├── dataModel.d.ts │ │ ├── server.d.ts │ │ └── server.js │ ├── account.ts │ ├── adminData.ts │ ├── adminStoryWrite.ts │ ├── adminWrite.ts │ ├── audioRead.ts │ ├── auth.config.ts │ ├── auth.ts │ ├── authFunctions.ts │ ├── authMigration.ts │ ├── betterAuth/ │ │ ├── _generated/ │ │ │ ├── api.ts │ │ │ ├── component.ts │ │ │ ├── dataModel.ts │ │ │ └── server.ts │ │ ├── adapter.ts │ │ ├── auth.ts │ │ ├── convex.config.ts │ │ └── schema.ts │ ├── convex-env.d.ts │ ├── convex.config.ts │ ├── convex_rules.md │ ├── courseContributorBackfill.ts │ ├── courseWrite.ts │ ├── discordAvatarSync.ts │ ├── discordBot.ts │ ├── discordData.ts │ ├── discordRoleSync.ts │ ├── editorRead.ts │ ├── editorSideEffects.ts │ ├── http.ts │ ├── landing.ts │ ├── languageWrite.ts │ ├── lib/ │ │ ├── authorization.ts │ │ ├── courseContributors.ts │ │ ├── courseCounts.ts │ │ ├── discordAvatarSync.ts │ │ ├── phpbb.ts │ │ └── publicStoryContent.ts │ ├── localization.ts │ ├── localizationWrite.ts │ ├── lookupTables.ts │ ├── roles.ts │ ├── schema.ts │ ├── storyApproval.ts │ ├── storyDone.ts │ ├── storyPublicContent.ts │ ├── storyRead.ts │ ├── storyTables.ts │ ├── storyWrite.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── userPreferences.ts ├── database/ │ └── stories/ │ ├── es-en-o/ │ │ ├── 1_1_es-en-buenos-dias.txt │ │ ├── 1_2_es-en-una-cita.txt │ │ └── 1_3_es-en-una-cosa.txt │ ├── nl-en/ │ │ ├── 0_1_es-en-el-pastel-de-dragones.txt │ │ ├── 0_2_es-en-es-amor.txt │ │ ├── 0_3_es-en-la-pelea-de-boxeo.txt │ │ ├── 0_4_es-en-el-neumatico-pinchado.txt │ │ ├── 1_1_es-buenos-dias.txt │ │ ├── 1_2_es-una-cita.txt │ │ ├── 1_3_es-una-cosa.txt │ │ ├── 1_4_es-en-la-luna-de-miel.txt │ │ ├── 2_1_es-en-la-chaqueta-roja.txt │ │ ├── 2_2_es-en-el-pasaporte.txt │ │ ├── 2_3_es-en-una-familia-muy-grande.txt │ │ └── 2_4_es-en-el-doctor-eddy.txt │ └── test-en/ │ ├── 1_1_es-en-buenos-dias.txt │ └── 1_2_es-en-una-cita.txt ├── discord_roles/ │ ├── CONTEXT.md │ ├── audio_cleanup.py │ ├── blame.py │ ├── combine.py │ ├── discord_bot.py │ ├── discord_reacting_bot.py │ ├── env_utils.py │ └── requirements.txt ├── docs/ │ └── bulk-audio-editor-spec.md ├── import_tools/ │ ├── README.md │ ├── app.py │ └── greasmonkey.js ├── instrumentation-client.ts ├── jsconfig.json ├── knip.json ├── next.config.js ├── package.json ├── postcss.config.mjs ├── process.d.ts ├── public/ │ ├── .well-known/ │ │ └── assetlinks.json │ ├── darklight.js │ ├── docs/ │ │ ├── audio-generation/ │ │ │ ├── character-editor.mdx │ │ │ ├── edit.mdx │ │ │ ├── engines.mdx │ │ │ ├── fix-problems.mdx │ │ │ ├── generate.mdx │ │ │ └── overview.mdx │ │ ├── become-contributor/ │ │ │ ├── application.mdx │ │ │ └── colang.mdx │ │ ├── docs.json │ │ ├── introduction.mdx │ │ ├── search.js │ │ ├── story-creation/ │ │ │ ├── import.mdx │ │ │ └── translate.mdx │ │ ├── story-editing/ │ │ │ ├── exercises.mdx │ │ │ ├── overview.mdx │ │ │ └── translation-hints.mdx │ │ └── story-publishing/ │ │ ├── publishing.mdx │ │ └── without_tts.mdx │ ├── linja-pona-4.9.otf │ ├── linjalipamanka-normal.otf │ ├── robots.txt │ └── sw.js ├── scripts/ │ ├── backfill-course-contributors.ts │ ├── backfill-discord-avatars.ts │ └── find-missing-story-images.ts ├── skills-lock.json ├── src/ │ ├── app/ │ │ ├── (stories)/ │ │ │ ├── (main)/ │ │ │ │ ├── EditorCommandPaletteClient.tsx │ │ │ │ ├── [course_id]/ │ │ │ │ │ ├── course_page_client.tsx │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_button.tsx │ │ │ │ ├── course-dropdown.tsx │ │ │ │ ├── course_list.tsx │ │ │ │ ├── faq/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── footer_links.tsx │ │ │ │ ├── get_course_data.ts │ │ │ │ ├── header.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── landing_stats_client.tsx │ │ │ │ ├── language_button.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── privacy_policy/ │ │ │ │ │ └── page.tsx │ │ │ │ └── profile/ │ │ │ │ ├── actions.ts │ │ │ │ ├── data.ts │ │ │ │ ├── page.tsx │ │ │ │ └── profile.tsx │ │ │ ├── learn/ │ │ │ │ ├── page.tsx │ │ │ │ └── welcome.tsx │ │ │ └── story/ │ │ │ ├── [story_id]/ │ │ │ │ ├── auto_play/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_wrapper.tsx │ │ │ │ ├── getStory.ts │ │ │ │ ├── loading.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── script/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_wrapper.tsx │ │ │ │ ├── story_wrapper.tsx │ │ │ │ └── test/ │ │ │ │ ├── page.tsx │ │ │ │ └── story_wrapper.tsx │ │ │ └── layout.tsx │ │ ├── admin/ │ │ │ ├── AdminDialogTrigger.tsx │ │ │ ├── AdminHeader.tsx │ │ │ ├── FlagName.tsx │ │ │ ├── adminDetailStyles.ts │ │ │ ├── adminTableStyles.ts │ │ │ ├── courses/ │ │ │ │ ├── courses.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── page_client.tsx │ │ │ ├── edit_dialog.tsx │ │ │ ├── languages/ │ │ │ │ ├── language_list.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── page_client.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── story/ │ │ │ │ ├── [story_id]/ │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── story_display.tsx │ │ │ │ └── page.tsx │ │ │ └── users/ │ │ │ ├── [user_id]/ │ │ │ │ ├── actions.ts │ │ │ │ ├── page.tsx │ │ │ │ ├── schema.ts │ │ │ │ └── user_display.tsx │ │ │ ├── page.tsx │ │ │ └── user_list.tsx │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ └── [...all]/ │ │ │ │ └── route.ts │ │ │ ├── og/ │ │ │ │ └── route.tsx │ │ │ ├── og-course/ │ │ │ │ └── route.tsx │ │ │ └── og-story/ │ │ │ └── route.tsx │ │ ├── audio/ │ │ │ ├── _lib/ │ │ │ │ └── audio/ │ │ │ │ ├── azure_tts.ts │ │ │ │ ├── elevenlabs.ts │ │ │ │ ├── google.ts │ │ │ │ ├── index.ts │ │ │ │ ├── polly.ts │ │ │ │ └── types.ts │ │ │ ├── create/ │ │ │ │ └── route.ts │ │ │ ├── elevenlabs_quota/ │ │ │ │ └── page.tsx │ │ │ ├── upload/ │ │ │ │ └── route.ts │ │ │ └── voices/ │ │ │ └── route.ts │ │ ├── auth/ │ │ │ ├── admin/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── editor/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── register/ │ │ │ │ ├── page.tsx │ │ │ │ └── register.tsx │ │ │ ├── reset_pw/ │ │ │ │ ├── page.tsx │ │ │ │ └── reset_pw.tsx │ │ │ └── signin/ │ │ │ ├── login_options.tsx │ │ │ └── page.tsx │ │ ├── dev/ │ │ │ └── story-footer-button-test/ │ │ │ ├── page.module.css │ │ │ └── page.tsx │ │ ├── docs/ │ │ │ ├── [[...slug]]/ │ │ │ │ ├── doc_data.ts │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── loading.tsx │ │ ├── editor/ │ │ │ ├── (course)/ │ │ │ │ ├── course/ │ │ │ │ │ └── [course_id]/ │ │ │ │ │ ├── import/ │ │ │ │ │ │ └── [from_id]/ │ │ │ │ │ │ ├── import_list.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── localization/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── page_client.tsx │ │ │ │ │ ├── story/ │ │ │ │ │ │ └── [story]/ │ │ │ │ │ │ ├── audio-cutter/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── voices/ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── page_client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── page_client.tsx │ │ │ │ ├── course_list.tsx │ │ │ │ ├── course_view_memory.ts │ │ │ │ ├── edit_list.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── layout_client.tsx │ │ │ │ ├── layout_flag.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── swipe.tsx │ │ │ │ └── types.ts │ │ │ ├── _components/ │ │ │ │ ├── breadcrumbs.tsx │ │ │ │ ├── editor_command_palette.tsx │ │ │ │ ├── header_context.tsx │ │ │ │ ├── header_shell.tsx │ │ │ │ ├── page_layout.tsx │ │ │ │ └── story_editor_preferences.tsx │ │ │ ├── editor_button.tsx │ │ │ ├── language/ │ │ │ │ └── [language]/ │ │ │ │ ├── language_editor.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── page_client.tsx │ │ │ │ ├── tts_edit/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── page_client.tsx │ │ │ │ │ └── tts_edit.tsx │ │ │ │ └── types.ts │ │ │ ├── layout.tsx │ │ │ ├── localization/ │ │ │ │ └── [language]/ │ │ │ │ ├── layout.tsx │ │ │ │ ├── localization_editor.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── page_client.tsx │ │ │ │ └── text_edit.tsx │ │ │ └── story/ │ │ │ └── [story]/ │ │ │ ├── audio-cutter-dialog.tsx │ │ │ ├── audio-cutter-storage.ts │ │ │ ├── bulk-audio-editor.tsx │ │ │ ├── editor_state.ts │ │ │ ├── header.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── page_client.tsx │ │ │ ├── sound-recorder.tsx │ │ │ ├── types.ts │ │ │ └── v2/ │ │ │ ├── editor_v2.tsx │ │ │ └── use_story_editor_model.ts │ │ ├── layout.tsx │ │ ├── manifest.json │ │ └── not-found.tsx │ ├── components/ │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ ├── CheckButton/ │ │ │ ├── CheckButton.tsx │ │ │ └── index.ts │ │ ├── ContributorList.tsx │ │ ├── Docs/ │ │ │ ├── CustomMDXServer/ │ │ │ │ ├── CustomMDXServer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── process_mdx.ts │ │ │ ├── MdxTree/ │ │ │ │ ├── MdxTree.tsx │ │ │ │ └── index.ts │ │ │ └── docsClasses.ts │ │ ├── DocsBreadCrumbNav/ │ │ │ ├── DocsBreadCrumbNav.tsx │ │ │ └── index.ts │ │ ├── DocsHeader/ │ │ │ ├── DocsHeader.tsx │ │ │ └── index.ts │ │ ├── DocsNavigation/ │ │ │ ├── DocsNavigation.tsx │ │ │ └── index.ts │ │ ├── DocsNavigationBackdrop/ │ │ │ ├── DocsNavigationBackdrop.tsx │ │ │ └── index.ts │ │ ├── DocsSearchModal/ │ │ │ ├── DocsSearchModal.tsx │ │ │ └── index.ts │ │ ├── EditorSSMLDisplay/ │ │ │ ├── EditorSSMLDisplay.tsx │ │ │ └── index.ts │ │ ├── FadeGlideIn/ │ │ │ ├── FadeGlideIn.tsx │ │ │ └── index.ts │ │ ├── LocalisationProvider/ │ │ │ ├── LocalisationProvider.tsx │ │ │ ├── LocalisationProviderContext.tsx │ │ │ └── index.ts │ │ ├── NavigationModeProvider/ │ │ │ ├── NavigationModeProvider.tsx │ │ │ └── index.ts │ │ ├── PlayAudio/ │ │ │ ├── PlayAudio.tsx │ │ │ └── index.ts │ │ ├── ProgressBar/ │ │ │ ├── ProgressBar.tsx │ │ │ └── index.ts │ │ ├── StoryAutoPlay/ │ │ │ ├── StoryAutoPlay.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeArrange/ │ │ │ ├── StoryChallengeArrange.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeContinuation/ │ │ │ ├── StoryChallengeContinuation.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeMatch/ │ │ │ ├── StoryChallengeMatch.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeMultipleChoice/ │ │ │ ├── StoryChallengeMultipleChoice.tsx │ │ │ └── index.ts │ │ ├── StoryChallengePointToPhrase/ │ │ │ ├── StoryChallengePointToPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryChallengeSelectPhrases/ │ │ │ ├── StoryChallengeSelectPhrases.tsx │ │ │ └── index.ts │ │ ├── StoryEditorPreview/ │ │ │ ├── StoryEditorPreview.tsx │ │ │ └── index.ts │ │ ├── StoryFinishedScreen/ │ │ │ ├── StoryFinishedScreen.tsx │ │ │ └── index.ts │ │ ├── StoryFooter/ │ │ │ ├── StoryFooter.tsx │ │ │ └── index.ts │ │ ├── StoryHeader/ │ │ │ ├── StoryHeader.tsx │ │ │ └── index.ts │ │ ├── StoryHeaderProgress/ │ │ │ ├── StoryHeaderProgress.tsx │ │ │ └── index.ts │ │ ├── StoryLineHints/ │ │ │ ├── StoryLineHints.tsx │ │ │ └── index.ts │ │ ├── StoryProgress/ │ │ │ ├── StoryProgress.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionArrange/ │ │ │ ├── StoryQuestionArrange.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionMatch/ │ │ │ ├── StoryQuestionMatch.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionMultipleChoice/ │ │ │ ├── StoryQuestionMultipleChoice.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionPointToPhrase/ │ │ │ ├── StoryQuestionPointToPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionPrompt/ │ │ │ ├── StoryQuestionPrompt.tsx │ │ │ └── index.ts │ │ ├── StoryQuestionSelectPhrase/ │ │ │ ├── StoryQuestionSelectPhrase.tsx │ │ │ └── index.ts │ │ ├── StoryTextLine/ │ │ │ ├── StoryTextLine.tsx │ │ │ ├── index.ts │ │ │ └── use-audio.hook.ts │ │ ├── StoryTextLineSimple/ │ │ │ ├── StoryTextLineSimple.tsx │ │ │ └── index.ts │ │ ├── StoryTitlePage/ │ │ │ ├── StoryTitlePage.tsx │ │ │ └── index.ts │ │ ├── VisuallyHidden/ │ │ │ ├── VisuallyHidden.tsx │ │ │ └── index.ts │ │ ├── WordButton/ │ │ │ ├── WordButton.tsx │ │ │ └── index.ts │ │ ├── auth/ │ │ │ └── styles.ts │ │ ├── editor/ │ │ │ └── story/ │ │ │ ├── cast.tsx │ │ │ ├── editor-resize.ts │ │ │ ├── inline_tts.ts │ │ │ ├── parser.test.ts │ │ │ ├── parser.ts │ │ │ ├── scroll_linking.ts │ │ │ ├── syntax_parser_new.ts │ │ │ └── syntax_parser_types.ts │ │ ├── icons.tsx │ │ ├── layout/ │ │ │ └── legal.tsx │ │ ├── login/ │ │ │ ├── LoggedInButtonWrappedClient.tsx │ │ │ └── loggedinbutton.tsx │ │ ├── providers/ │ │ │ ├── ConvexClientProvider.tsx │ │ │ └── PostHogUserIdentifier.tsx │ │ └── ui/ │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── flag.tsx │ │ ├── input.tsx │ │ ├── kbd.tsx │ │ ├── language-flag.tsx │ │ ├── shadcn/ │ │ │ ├── dropdown-menu.tsx │ │ │ └── index.ts │ │ ├── sheet.tsx │ │ ├── spinner.tsx │ │ └── switch.tsx │ ├── hooks/ │ │ ├── use-choice-buttons.hook.ts │ │ ├── use-keypress.hook.ts │ │ └── use-scroll-into-view.hook.ts │ ├── instrumentation-client.ts │ ├── lib/ │ │ ├── audio/ │ │ │ └── client-audio-processing.ts │ │ ├── auth-client.ts │ │ ├── auth-server.ts │ │ ├── editor/ │ │ │ ├── audio/ │ │ │ │ ├── audio_edit_tools.test.ts │ │ │ │ ├── audio_edit_tools.ts │ │ │ │ └── text_with_mapping.ts │ │ │ ├── editorHandlers.ts │ │ │ └── tts_transcripte.ts │ │ ├── fetch_post.ts │ │ ├── getUserId.ts │ │ ├── get_localisation.ts │ │ ├── get_localisation_func.tsx │ │ ├── hooks.ts │ │ ├── is-typing-target.ts │ │ ├── lamejs-compat.ts │ │ ├── posthog-server.ts │ │ ├── posthog-user.ts │ │ ├── shuffle.ts │ │ ├── sound-effects.ts │ │ ├── story-preferences.ts │ │ ├── story-search.ts │ │ ├── userInterface.ts │ │ └── utils.ts │ ├── styles/ │ │ └── global.css │ └── types/ │ ├── lamejs.d.ts │ └── react-dom.d.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codex/environments/environment.toml ================================================ # THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY version = 1 name = "unofficial-duolingo-stories" [setup] script = ''' pnpm i cp /Users/richard/WebstormProjects/unofficial-duolingo-stories_bugfixes/.env.local . ''' [[actions]] name = "Run" icon = "run" command = ''' pnpm i pnpm run dev ''' ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: duostories ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/workflows/build.yml ================================================ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Build on: push: branches: ["master"] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: 'npm' node-version: 20 - uses: actions/cache@v3 with: # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node path: | ~/.npm ${{ github.workspace }}/.next/cache # Generate a new cache whenever packages or source files change. key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} # If source files changed but packages didn't, rebuild from a prior cache. restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - name: ssh tunnel to mysql database run: | mkdir -p ~/.ssh/ ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de - name: Install run: npm install - run: printf "${{ secrets.ENV_LOCAL }}" >> .env.local - name: Build run: npm run build - name: zip run: zip -r build.zip .next - name: Archive production artifacts uses: actions/upload-artifact@v3 with: name: build-data path: build.zip deploy-beta: needs: build runs-on: ubuntu-latest steps: - name: Download build artifacts uses: actions/download-artifact@v4.1.7 with: name: build-data - name: ssh tunnel for upload run: | mkdir -p ~/.ssh/ ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de ssh -fN -L 5432:127.0.0.1:5432 duostori@ara.uberspace.de - name: upload run: | ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" scp build.zip duostori@ara.uberspace.de:~/html/HEAD/ - name: unzip run: | ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh duostori@ara.uberspace.de 'rm -rf ~/html/HEAD/.next' ssh duostori@ara.uberspace.de 'unzip -d ~/html/HEAD/ ~/html/HEAD/build.zip' - name: restart run: | ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh duostori@ara.uberspace.de 'supervisorctl stop beta' ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py' ssh duostori@ara.uberspace.de '/home/duostori/html/kill_port_users.py beta' ssh duostori@ara.uberspace.de 'supervisorctl start beta' ================================================ FILE: .github/workflows/build_pr.yml ================================================ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Build Pull Request on: pull_request: branches: ["master"] jobs: build_pr: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: 20 - name: ssh tunnel to mysql database run: | mkdir -p ~/.ssh/ ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh -fN -L 3306:127.0.0.1:3306 duostori@ara.uberspace.de - name: Install run: npm install - run: printf "${{ secrets.ENV_LOCAL }}" >> .env.local - name: Build run: npm run build - name: zip run: zip -r build.zip .next - name: upload run: | ssh-keyscan -H ara.uberspace.de >> ~/.ssh/known_hosts eval `ssh-agent -s` ssh-add - <<< "${{secrets.SSH_PRIVATE_KEY}}" ssh duostori@ara.uberspace.de 'cd html && ./create_deployment.py ${{ github.event.number }}' scp build.zip duostori@ara.uberspace.de:~/html/deploy_${{ github.event.number }}/ ssh duostori@ara.uberspace.de 'rm -rf ~/html/deploy_${{ github.event.number }}/.next' ssh duostori@ara.uberspace.de 'unzip -d ~/html/deploy_${{ github.event.number }}/ ~/html/deploy_${{ github.event.number }}/build.zip' ssh duostori@ara.uberspace.de 'supervisorctl stop deploy_${{ github.event.number }}' ssh duostori@ara.uberspace.de '/home/duostori/html/kill_rouge_workers.py' ssh duostori@ara.uberspace.de 'supervisorctl start deploy_${{ github.event.number }}' ================================================ FILE: .github/workflows/ci.yaml ================================================ name: CI on: [push] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - name: Setup pnpm uses: pnpm/action-setup@v5 with: version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install Dependencies run: pnpm install --frozen-lockfile - name: Copy .env.example files shell: bash run: find . -type f -name ".env.example" -exec sh -c 'cp "$1" "${1%.*}"' _ {} \; - name: Typecheck run: pnpm typecheck - name: Lint run: pnpm lint ================================================ FILE: .gitignore ================================================ .idea/ sql_dump/ /audio/rootkey.csv /audio/tts/rootkey.csv node_modules/ rootkey.csv lib_swift/ dist/ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules .pnp .pnp.js # testing coverage # production build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* /duolingo_data/ token.txt /import_tools/duolingo_data/ duolingo_data* sync.sh sync_test.sh publishX.sh /packages/**/cypress/videos/ /packages/**/cypress/**/screenshots/ /src/next-all/test.sql # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # next.js .next/ out/ tmp/ # misc .env* old/ /test.sqlite .vercel *storybook.log next-env.d.ts tsconfig.tsbuildinfo discord_roles/.cache/ */skills/* skills/* ================================================ FILE: AGENTS.md ================================================ # Agent Notes ## Formatting - Run `pnpm run format` after code edits in this repository. - Use `pnpm run format:check` for CI/local validation. - Biome is the default formatter/linter for this repo. - Biome checks are intentionally scoped to `src/` and `convex/`. - Run `pnpm run lint` before finishing when lint-sensitive files changed. ## Type Checking - Run `pnpm typecheck` after code edits and before finishing. ## Convex - Follow `./convex/convex_rules.md` when making changes in `convex/`. - Always deploy Convex after changing files in `convex/` to the dev deployment (for example with `pnpm convex dev --once`), not prod. # Next.js: ALWAYS read docs before coding Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. This project uses [Convex](https://convex.dev) as its backend. When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Unofficial Duolingo Stories (https://duostories.org) - a community-driven platform that brings Duolingo Stories to new languages through community translation. Built with Next.js 16 (App Router) and React 19, with Convex as the canonical app data layer. ## Development Commands ```bash pnpm run dev # Development server at http://localhost:3000 pnpm run build # Production build pnpm run format # Biome formatter on src/ and convex/ pnpm run lint # Biome linter (with format check first) pnpm run typecheck # TypeScript type checking (tsc --noEmit) pnpm exec convex codegen # Regenerate Convex bindings after adding/changing Convex functions ``` Note: TypeScript build errors are ignored in `next.config.js` (`ignoreBuildErrors: true`), so `pnpm run build` will succeed even with type errors. Use `pnpm run typecheck` to check types separately. ## Environment Setup Requires Convex. Next.js `.env.local` typically includes: - `NEXT_PUBLIC_CONVEX_URL` and `CONVEX_URL` - `BETTER_AUTH_SECRET` - `SITE_URL` Convex env (`pnpm exec convex env set ...`) typically includes: - `GITHUB_REPO_TOKEN` - used by `convex/editorSideEffects.ts` - `POSTHOG_KEY` and `POSTHOG_HOST` - used by `convex/editorSideEffects.ts` - `RESEND_API_KEY`, `SITE_URL`, `BETTER_AUTH_SECRET` Test credentials: user/test (normal), editor/test (editor access), admin/test (admin access) ## Architecture ### Directory Structure - `src/app/` - Next.js App Router pages - `(stories)/` - Main story browsing (route group, includes story reader, course listing, profile, FAQ) - `admin/` - Admin dashboard - `editor/` - Story editor interface - `auth/` - Authentication (signin, register, password reset) - `api/` - API routes (auth handler, OG image generation) - `audio/` - Audio processing endpoints - `src/components/` - Reusable React components - `src/lib/` - Server utilities, database helpers, auth ### Key Files - `src/auth.ts` - Better Auth server configuration (JWT sessions, OAuth providers, email verification) - `src/lib/auth-client.ts` - Client-side Better Auth client - `convex/editorSideEffects.ts` - GitHub/PostHog side effects scheduled by write mutations - `convex/lib/authorization.ts` - shared auth guard helpers for Convex functions ### Authentication Uses Better Auth with JWT sessions (5-minute cookie cache). Supports email/password and OAuth (GitHub, Google, Facebook, Discord). Custom table names map to legacy schema (e.g., `user_better_auth`, `session_better_auth`). User model has custom `role` and `admin` fields. ### Database Access Application reads/writes should go through Convex queries/mutations. ### Write-path Rules - Prefer direct client/server-action calls to Convex mutations for app writes. - Do not add pass-through Next route handlers for simple reads/writes. - Use Next route handlers only for server-only concerns (auth entrypoint, file upload, external secrets/integration boundaries). - Schedule side effects from Convex mutations using `ctx.scheduler.runAfter(..., internal...)`. - Include `operationKey` for retriable writes. - Keep side effects non-blocking: DB mutation success should not depend on GitHub/PostHog success. ### Component Pattern ``` /ComponentName ├── ComponentName.tsx # Implementation ├── ComponentName.module.css # CSS Module styles └── index.ts # Export ``` ### Styling - CSS Modules for scoped styles (primary) - Styled Components for dynamic styles (compiler enabled in `next.config.js`) - Global styles in `src/styles/global.css` ### Path Alias `@/` maps to `src/` (tsconfig baseUrl is `src`, paths `@/*` → `./*`). ## Story Workflow Stories have a status workflow: draft → feedback → finished. Stories belong to courses, which link a learning language to a base language. ## Audio/TTS Multiple TTS providers in `src/app/audio/_lib/audio/`: Azure, Google Cloud, AWS Polly, ElevenLabs. This project uses [Convex](https://convex.dev) as its backend. When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. ================================================ FILE: CONTEXT.md ================================================ # Unofficial Duolingo Stories This context describes the domain language for publishing and editing community-translated Duolingo-style stories. ## Language **Story**: A course-bound learning unit composed of dialogue, prompts, audio, and metadata. _Avoid_: Lesson, exercise, activity **Deleted Story**: A story hidden from normal workflows without permanently removing its record. _Avoid_: Archived story, permanently deleted story **Story Title**: The learner-facing title of a story. _Avoid_: Story name **Course**: A container for stories for one learning-language/from-language pair. _Avoid_: Language, localization, course language **Course Slug**: The compact course identifier used in URLs and course selection. _Avoid_: Short code, language code **Language**: A reusable language record used by courses, voices, localization strings, avatar mappings, and flags. _Avoid_: Course **Language Code**: The compact identifier for a single language. _Avoid_: Course slug **Learning Language**: The language a course teaches. _Avoid_: Target language **From Language**: The language a course assumes the learner already understands. _Avoid_: Base language, source language **Localization**: A translated UI/app text string for a language. _Avoid_: Story translation, course translation **Story Content**: The editable body of a story, represented both as source text and structured content. _Avoid_: Story metadata **Story Text**: The author-facing textual representation of story content. _Avoid_: Script **Story Text Syntax**: The markup and conventions contributors use inside story text. _Avoid_: Format, parser syntax **Structured Story Content**: The parsed representation of story content used by editors and readers. _Avoid_: Story JSON **Story Element**: One editable item inside a story body, such as a dialogue line, prompt, or challenge. _Avoid_: Line, item **Line**: A text-bearing story element that can have speaker text and audio. _Avoid_: Story element, file line **Character**: A visible or narrative persona used in story content. _Avoid_: Speaker, avatar, voice **Character Name**: The display name for a character in a course or language context. _Avoid_: Avatar **Cast**: The set of characters used by a story, with their names, avatars, and voices resolved for the course. _Avoid_: Course characters **Avatar**: A reusable visual asset that can represent a character. _Avoid_: Character, speaker, voice **Avatar Mapping**: A course- or language-specific assignment of an avatar to a character name and voice. _Avoid_: Speaker mapping **Voice**: A text-to-speech voice or configuration used for story audio. _Avoid_: Speaker, character, avatar **Audio File**: A media file used for story playback. _Avoid_: Audio timing, voice **Uploaded Audio**: An audio file provided by a contributor. _Avoid_: Generated audio **Generated Audio**: An audio file produced from text using a voice. _Avoid_: Uploaded audio **Audio Timing**: Timing data that synchronizes story text with audio playback. _Avoid_: Audio file, voice **Story Header**: The opening story element containing a title, illustration, learning-language content, and optional audio. _Avoid_: Header, page header, app header **Story Illustration**: The visual associated with a story or story header. _Avoid_: Story image, image **Challenge**: An interactive story element that asks the learner to respond. _Avoid_: Question, exercise **Story Status**: The editorial lifecycle state of a story. _Avoid_: Publication status **Draft**: A story status for work that is still being prepared. _Avoid_: Unpublished **Feedback**: A story status for work that has received one approval and is ready for another contributor to review. _Avoid_: Ready for feedback, review **Finished**: A story status for work that has completed the editorial workflow. _Avoid_: Done **Approval**: A contributor review signal in the story editorial workflow. _Avoid_: Publication, finished **Publication Visibility**: Whether a story is visible to learners. _Avoid_: Story status, finished **Published**: A publication visibility value meaning a story is visible to learners. _Avoid_: Finished **Unpublished**: A publication visibility value meaning a story is not visible to learners. _Avoid_: Draft **Publish**: To change story visibility to Published. _Avoid_: Finish, release **Story Completion**: A learner-side record that a user completed a story. _Avoid_: Finished, done **TODO**: A marker written in story text by a contributor to flag something for later attention. _Avoid_: Task, issue **Hint**: Supporting text shown to help learners understand a word or phrase. _Avoid_: Translation **Pronunciation Hint**: Supporting text shown to help learners pronounce a word or phrase. _Avoid_: Hint, pinyin **Selectable Phrase**: A phrase segment the learner can choose or arrange inside a challenge. _Avoid_: Button **Story Set**: A group of stories within a course that is usually published together. _Avoid_: Course, story status **Set 0**: An introductory story set outside the main course canon. _Avoid_: Main story set **Source Course**: A course used as the reference when importing stories for translation into another course. _Avoid_: Main canon **Official Course**: A non-public course imported from Duolingo as raw source material. _Avoid_: Public course, endorsed course **Public Course**: A course visible to learners on the site. _Avoid_: Published story **Course Page**: The public learner-facing page for a course and its published stories. _Avoid_: Editor course story overview **Target Course**: A course that receives imported or translated stories. _Avoid_: Target language **Story Import**: The act of creating a target-course story from a source-course story. _Avoid_: Translation **Translation**: The linguistic work of adapting story content into the target course's learning language. _Avoid_: Story import **Learner**: A user who consumes stories. _Avoid_: Contributor **Contributor**: A user with global permission to edit project content. _Avoid_: Editor **Admin**: A user with project-wide management permissions beyond normal contribution. _Avoid_: Contributor **Course Contributor**: A contributor credited for making a minimum contribution to a course. _Avoid_: Course-scoped editor, course permission **Editor Area**: The authenticated contributor-facing part of the site under `/editor`. _Avoid_: Story editor, contributor **Story Editor**: The editor workspace for one specific story. _Avoid_: Editor area, contributor **Bulk Audio Editor**: A story-level workspace for assigning many audio files and audio timings before applying them to a story. _Avoid_: Audio cutter, voice editor **Audio Cutter**: A tool or workflow for preparing audio segments from longer audio. _Avoid_: Bulk audio editor **Story Page**: The learner-facing page for reading or playing one published story. _Avoid_: Story editor **Editor Course Story Overview**: The editor-area screen for viewing and working through one course's stories. _Avoid_: Course editor, course story overview **Character Voice Editor**: The editor-area screen for assigning character names and voices in a course context. _Avoid_: Voice editor, speaker editor **Voice Catalog Editor**: The editor-area screen for managing available voices. _Avoid_: Character voice editor, speaker editor **Course Localization Editor**: The editor-area screen for editing localization strings in a course context. _Avoid_: Story translation editor ## Relationships - A **Course** owns zero or more **Stories**. - A **Course** owns zero or more **Story Sets**. - A **Course** has one **Course Slug** when it is addressable in the editor or site. - A **Course** can be a **Public Course** or non-public. - A **Public Course** can have one **Course Page**. - A **Course** can act as a **Source Course** for story imports. - A **Course** can act as a **Target Course** for story imports. - An **Official Course** can act as raw material for **Story Imports**. - A **Source Course** provides stories to one or more **Target Courses**. - A **Story Import** copies a **Story** from a **Source Course** into a **Target Course**. - **Translation** can happen after a **Story Import**. - A **Learner** can create **Story Completions**. - A **Contributor** can edit project content across courses. - A **Contributor** can use the **Editor Area**. - An **Editor Course Story Overview** belongs to one **Course**. - An **Editor Course Story Overview** is part of the **Editor Area**. - A **Character Voice Editor** works in one **Course** context. - A **Character Voice Editor** is part of the **Editor Area**. - A **Voice Catalog Editor** is part of the **Editor Area**. - A **Voice Catalog Editor** manages **Voices**. - A **Course Localization Editor** belongs to one **Course**. - A **Course Localization Editor** edits **Localization** entries. - A **Course Localization Editor** is part of the **Editor Area**. - A **Story Editor** edits one **Story**. - A **Bulk Audio Editor** belongs to one **Story Editor**. - A **Bulk Audio Editor** edits **Audio Files** and **Audio Timing** for a **Story**. - An **Audio Cutter** can produce **Audio Files** for a **Story**. - A **Story Page** presents one **Published** **Story** to **Learners**. - An **Admin** can manage content and settings beyond a normal **Contributor**'s scope. - A **Course Contributor** is credited on public and internal course pages. - A **Story Set** contains one or more **Stories**. - A **Course** has exactly one learning **Language**. - A **Course** has exactly one from **Language**. - A **Language** has one **Language Code**. - A **Localization** belongs to exactly one **Language**. - A **Story** belongs to exactly one **Course**. - A **Story** belongs to exactly one **Story Set**. - A **Story** has exactly one **Story Title**. - A **Story** has exactly one **Story Status**. - A **Story** can be a **Deleted Story**. - A **Story** can receive zero or more **Approval** signals. - A **Story** becomes **Feedback** when it receives its first **Approval**. - A **Story** becomes **Finished** when it receives two **Approval** signals. - A **Story** has one **Publication Visibility**. - A **Story** can have zero or more **Story Completion** records. - A **Story Completion** belongs to one **Learner**. - A **Story Text** can contain zero or more **TODO** markers. - **Story Content** can include **Hint** entries for learner-facing text. - **Story Content** can include **Pronunciation Hint** entries for learner-facing text. - A **Challenge** can contain one or more **Selectable Phrase** entries. - A regular **Story Set** is published when all four of its **Stories** are **Finished**. - An **Approval** can indirectly trigger publication when it causes the final **Story** in a regular **Story Set** to become **Finished**. - **Publish** changes one or more **Stories** from **Unpublished** to **Published**. - **Set 0** can contain fewer than four introductory **Stories**. - A **Story** has exactly one **Story Content** body. - **Story Content** has one **Story Text** representation. - **Story Text** uses **Story Text Syntax**. - **Story Content** has one **Structured Story Content** representation. - **Story Content** contains one or more **Story Elements**. - A **Story Header** is a kind of **Story Element**. - A **Line** is a kind of **Story Element**. - A **Challenge** is a kind of **Story Element**. - A **Story** can have one **Story Illustration**. - A **Story Header** can show one **Story Illustration**. - A **Story Header** can have one **Audio File**. - A **Story Header** can have **Audio Timing** for its **Audio File**. - A **Line** can be associated with one **Character**. - A **Character** can have one **Character Name** in a course or language context. - A **Character** can use one **Avatar** for display. - A **Character** can use one **Voice** for generated audio. - A **Story** has one **Cast**. - A **Cast** contains one or more **Character** entries. - An **Avatar Mapping** connects an **Avatar** to a **Character Name** and **Voice**. - **Uploaded Audio** is a kind of **Audio File**. - **Generated Audio** is a kind of **Audio File**. - A **Line** can have one **Audio File**. - A **Line** can have **Audio Timing** for its **Audio File**. ## Example dialogue > **Dev:** "Can we create a **Story** before choosing where it belongs?" > **Domain expert:** "No — every **Story** belongs to a **Course**." ## Flagged ambiguities - "language" is often used informally to mean **Course**, but should be reserved for **Language** unless the learning/from-language pair is irrelevant. - "line" can mean a story **Line** or a file line number; use **Line** only for story content. - "header" can mean a **Story Header** or a UI layout header; use **Story Header** for story content. - "speaker" is overloaded in code and UI; use **Voice** for text-to-speech configuration and **Character** for the story persona. - "main canon" is not established project language; when referring to import/source guidance, discuss the source course explicitly. - "editor" can mean the **Editor Area**, the **Story Editor**, or the person doing edits; use **Contributor** for the person. - "course contributor" is attribution, not authorization; contributors currently have global edit access rather than per-course edit permissions. - "done" appears in learner completion data; use **Story Completion** for learner progress and **Finished** for editorial status. - "official" means imported from Duolingo as source material; it does not mean this project is endorsed by Duolingo. - The first **Approval** often represents an author saying a story is ready for feedback, but the system does not require that approval to come from an author. - **Approval** signals currently persist across story edits; edits do not reset or stale existing approvals. ================================================ FILE: README.md ================================================ # Unofficial Duolingo Stories [![Cypress Test](https://img.shields.io/endpoint?url=https://cloud.cypress.io/badge/simple/cvszgh/master&style=flat&logo=cypress)](https://cloud.cypress.io/projects/cvszgh/runs) [![chat](https://img.shields.io/discord/726701782075572277)](https://discord.com/invite/4NGVScARR3) This project brings the official Duolingo Stories to new languages, translated by a community effort. It is _not_ an official product of Duolingo, nor is there any plan to integrate it into their platform or app. It is hosted at https://duostories.org and reproduces the story experience from the official Duolingo stories. The app is built with Next.js and React. ## Architecture snapshot - App/UI: Next.js 16 + React 19 (`src/app`, `src/components`) - Canonical app data access: Convex queries/mutations (`convex/*`) - Write-side side effects (GitHub/PostHog): Convex internal actions in `convex/editorSideEffects.ts` - Remaining Next route handlers are intentionally server-only: - Auth entrypoint (`src/app/api/auth/[...all]/route.ts`) - Audio endpoints (`src/app/audio/*/route.ts`) ### Write flow Client component -> Convex mutation -> schedule internal actions: - `editorSideEffects.*` for GitHub/PostHog side effects This keeps write authorization, mutation semantics, and side effects centralized in Convex. ## How to run locally Now create `.env.local` in the project root. Minimum local values: ``` NEXT_PUBLIC_CONVEX_URL= CONVEX_URL= BETTER_AUTH_SECRET= SITE_URL=http://localhost:3000 ``` Convex runtime env (set via `pnpm exec convex env set ...`) should include: ``` GITHUB_REPO_TOKEN= POSTHOG_KEY= POSTHOG_HOST= RESEND_API_KEY= SITE_URL=http://localhost:3000 BETTER_AUTH_SECRET= ``` Install dependencies ``` pnpm install ``` To develop you can then run and visit http://localhost:3000 ``` pnpm run dev ``` Recommended checks: ``` pnpm run typecheck pnpm run lint ``` ## How to contribute To contribute to the project you should open an issue to discuss your proposed change. You can assign the issue to yourself to show that you want to work on that. If there is a consensus that this bug should be fixed or this feature should be implemented, then follow the following steps: - create a fork of the repository - clone it to your computer - create a branch for your feature - make the changes to the code - commit and push the changes to GitHub - create a pull request Please make sure to only commit changes to files that are necessary to the issue. Try to not commit accidentally other changes, e.g. package-lock.json files. This makes it harder to review and merge the pull request. ### Contribution rules for new backend work - New app writes should be direct Convex mutations from the client or server action. - Avoid adding pass-through Next route handlers for simple reads/writes. - Server side effects should be scheduled from Convex mutations via internal actions. - Include an `operationKey` for mutation calls that can be retried. If everything is fine, I will accept the pull request and I will soon upload it to the website. ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, "includes": [ "src/**", "convex/**", "!convex/_generated/**", "!convex/**/_generated/**" ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, "linter": { "enabled": true, "rules": { "recommended": false, "a11y": { "noAriaUnsupportedElements": "warn", "useAltText": "warn", "useAriaPropsForRole": "warn", "useAriaPropsSupportedByRole": "warn", "useValidAriaProps": "warn", "useValidAriaValues": "warn" }, "correctness": { "noChildrenProp": "error", "noNextAsyncClientComponent": "warn", "useExhaustiveDependencies": "warn", "useHookAtTopLevel": "error", "useJsxKeyInIterable": "error" }, "performance": { "noImgElement": "off", "noUnwantedPolyfillio": "warn", "useGoogleFontPreconnect": "warn" }, "security": { "noDangerouslySetInnerHtmlWithChildren": "error" }, "style": { "noHeadElement": "warn" }, "suspicious": { "noCommentText": "error", "noDocumentImportInPage": "error", "noDuplicateJsxProps": "error", "noHeadImportInDocument": "error", "useGoogleFontDisplay": "warn" } }, "includes": [ "src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", "convex/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", "!convex/_generated/**", "!convex/**/_generated/**" ] }, "javascript": { "formatter": { "quoteStyle": "double" } }, "css": { "parser": { "tailwindDirectives": true } }, "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } } } ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "src/styles/global.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "iconLibrary": "lucide", "rtl": false, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "registries": {} } ================================================ FILE: convex/_generated/ai/ai-files.state.json ================================================ { "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", "agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d", "installedSkillNames": [ "convex", "convex-create-component", "convex-migration-helper", "convex-performance-audit", "convex-quickstart", "convex-setup-auth" ] } ================================================ FILE: convex/_generated/ai/guidelines.md ================================================ # Convex guidelines ## Function guidelines ### Http endpoint syntax - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: ```typescript import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; const http = httpRouter(); http.route({ path: "/echo", method: "POST", handler: httpAction(async (ctx, req) => { const body = await req.bytes(); return new Response(body, { status: 200 }); }), }); ``` - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. ### Validators - Below is an example of an array validator: ```typescript import { mutation } from "./_generated/server"; import { v } from "convex/values"; export default mutation({ args: { simpleArray: v.array(v.union(v.string(), v.number())), }, handler: async (ctx, args) => { //... }, }); ``` - Below is an example of a schema with validators that codify a discriminated union type: ```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ results: defineTable( v.union( v.object({ kind: v.literal("error"), errorMessage: v.string(), }), v.object({ kind: v.literal("success"), value: v.number(), }), ), ), }); ``` - Here are the valid Convex types along with their respective validators: Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Id | string | `doc._id` | `v.id(tableName)` | | | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | | Boolean | boolean | `true` | `v.boolean()` | | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | | Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | ### Function registration - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. - You CANNOT register a function through the `api` or `internal` objects. - ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. ### Function calling - Use `ctx.runQuery` to call a query from a query, mutation, or action. - Use `ctx.runMutation` to call a mutation from a mutation or action. - Use `ctx.runAction` to call an action from an action. - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, ``` export const f = query({ args: { name: v.string() }, handler: async (ctx, args) => { return "Hello " + args.name; }, }); export const g = query({ args: {}, handler: async (ctx, args) => { const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); return null; }, }); ``` ### Function references - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. - A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. ### Pagination - Define pagination using the following syntax: ```ts import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; export const listWithExtraArg = query({ args: { paginationOpts: paginationOptsValidator, author: v.string() }, handler: async (ctx, args) => { return await ctx.db .query("messages") .withIndex("by_author", (q) => q.eq("author", args.author)) .order("desc") .paginate(args.paginationOpts); }, }); ``` Note: `paginationOpts` is an object with the following properties: - `numItems`: the maximum number of documents to return (the validator is `v.number()`) - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents) ## Schema guidelines - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. - Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent. - Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record. ## Authentication guidelines - Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`. - Example `convex/auth.config.ts`: ```typescript export default { providers: [ { domain: "https://your-auth-provider.com", applicationID: "convex", }, ], }; ``` The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim. - Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier. - In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key. - NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`. - When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`: ```tsx import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); function App({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests. ## Typescript guidelines - You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. - Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table. - Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type. - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: ```ts import { query } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; export const exampleQuery = query({ args: { userIds: v.array(v.id("users")) }, handler: async (ctx, args) => { const idToUsername: Record, string> = {}; for (const userId of args.userIds) { const user = await ctx.db.get("users", userId); if (user) { idToUsername[user._id] = user.username; } } return idToUsername; }, }); ``` - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. ## Full text search guidelines - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: const messages = await ctx.db .query("messages") .withSearchIndex("search_body", (q) => q.search("body", "hello hi").eq("channel", "#general"), ) .take(10); ## Query guidelines - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. - If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. - Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. - Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned. - Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. ### Ordering - By default Convex always returns documents in ascending `_creationTime` order. - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. ## Mutation guidelines - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. - Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file. - `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: ```ts import { action } from "./_generated/server"; export const exampleAction = action({ args: {}, handler: async (ctx, args) => { console.log("This action does not return anything"); return null; }, }); ``` ## Scheduling guidelines ### Cron guidelines - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, ```ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; import { internalAction } from "./_generated/server"; const empty = internalAction({ args: {}, handler: async (ctx, args) => { console.log("empty"); }, }); const crons = cronJobs(); // Run `internal.crons.empty` every two hours. crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); export default crons; ``` - You can register Convex functions within `crons.ts` just like any other file. - If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. ## Testing guidelines - Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`. Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`: ```typescript /// import { convexTest } from "convex-test"; import { expect, test } from "vitest"; import { api } from "./_generated/api"; import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); test("some behavior", async () => { const t = convexTest(schema, modules); await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" }); const messages = await t.query(api.messages.list); expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]); }); ``` The `modules` argument is required so convex-test can discover and load function files. The `/// ` directive is needed for TypeScript to recognize `import.meta.glob`. ## File storage guidelines - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. ``` import { query } from "./_generated/server"; import { Id } from "./_generated/dataModel"; type FileMetadata = { _id: Id<"_storage">; _creationTime: number; contentType?: string; sha256: string; size: number; } export const exampleQuery = query({ args: { fileId: v.id("_storage") }, handler: async (ctx, args) => { const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); console.log(metadata); return null; }, }); ``` - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. ================================================ FILE: convex/_generated/api.d.ts ================================================ /* eslint-disable */ /** * Generated `api` utility. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type * as account from "../account.js"; import type * as adminData from "../adminData.js"; import type * as adminStoryWrite from "../adminStoryWrite.js"; import type * as adminWrite from "../adminWrite.js"; import type * as audioRead from "../audioRead.js"; import type * as auth from "../auth.js"; import type * as authFunctions from "../authFunctions.js"; import type * as authMigration from "../authMigration.js"; import type * as courseContributorBackfill from "../courseContributorBackfill.js"; import type * as courseWrite from "../courseWrite.js"; import type * as discordAvatarSync from "../discordAvatarSync.js"; import type * as discordBot from "../discordBot.js"; import type * as discordData from "../discordData.js"; import type * as discordRoleSync from "../discordRoleSync.js"; import type * as editorRead from "../editorRead.js"; import type * as editorSideEffects from "../editorSideEffects.js"; import type * as http from "../http.js"; import type * as landing from "../landing.js"; import type * as languageWrite from "../languageWrite.js"; import type * as lib_authorization from "../lib/authorization.js"; import type * as lib_courseContributors from "../lib/courseContributors.js"; import type * as lib_courseCounts from "../lib/courseCounts.js"; import type * as lib_discordAvatarSync from "../lib/discordAvatarSync.js"; import type * as lib_phpbb from "../lib/phpbb.js"; import type * as lib_publicStoryContent from "../lib/publicStoryContent.js"; import type * as localization from "../localization.js"; import type * as localizationWrite from "../localizationWrite.js"; import type * as lookupTables from "../lookupTables.js"; import type * as roles from "../roles.js"; import type * as storyApproval from "../storyApproval.js"; import type * as storyDone from "../storyDone.js"; import type * as storyPublicContent from "../storyPublicContent.js"; import type * as storyRead from "../storyRead.js"; import type * as storyTables from "../storyTables.js"; import type * as storyWrite from "../storyWrite.js"; import type * as userPreferences from "../userPreferences.js"; import type { ApiFromModules, FilterApi, FunctionReference, } from "convex/server"; declare const fullApi: ApiFromModules<{ account: typeof account; adminData: typeof adminData; adminStoryWrite: typeof adminStoryWrite; adminWrite: typeof adminWrite; audioRead: typeof audioRead; auth: typeof auth; authFunctions: typeof authFunctions; authMigration: typeof authMigration; courseContributorBackfill: typeof courseContributorBackfill; courseWrite: typeof courseWrite; discordAvatarSync: typeof discordAvatarSync; discordBot: typeof discordBot; discordData: typeof discordData; discordRoleSync: typeof discordRoleSync; editorRead: typeof editorRead; editorSideEffects: typeof editorSideEffects; http: typeof http; landing: typeof landing; languageWrite: typeof languageWrite; "lib/authorization": typeof lib_authorization; "lib/courseContributors": typeof lib_courseContributors; "lib/courseCounts": typeof lib_courseCounts; "lib/discordAvatarSync": typeof lib_discordAvatarSync; "lib/phpbb": typeof lib_phpbb; "lib/publicStoryContent": typeof lib_publicStoryContent; localization: typeof localization; localizationWrite: typeof localizationWrite; lookupTables: typeof lookupTables; roles: typeof roles; storyApproval: typeof storyApproval; storyDone: typeof storyDone; storyPublicContent: typeof storyPublicContent; storyRead: typeof storyRead; storyTables: typeof storyTables; storyWrite: typeof storyWrite; userPreferences: typeof userPreferences; }>; /** * A utility for referencing Convex functions in your app's public API. * * Usage: * ```js * const myFunctionReference = api.myModule.myFunction; * ``` */ export declare const api: FilterApi< typeof fullApi, FunctionReference >; /** * A utility for referencing Convex functions in your app's internal API. * * Usage: * ```js * const myFunctionReference = internal.myModule.myFunction; * ``` */ export declare const internal: FilterApi< typeof fullApi, FunctionReference >; export declare const components: { betterAuth: import("../betterAuth/_generated/component.js").ComponentApi<"betterAuth">; }; ================================================ FILE: convex/_generated/api.js ================================================ /* eslint-disable */ /** * Generated `api` utility. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. * * Usage: * ```js * const myFunctionReference = api.myModule.myFunction; * ``` */ export const api = anyApi; export const internal = anyApi; export const components = componentsGeneric(); ================================================ FILE: convex/_generated/dataModel.d.ts ================================================ /* eslint-disable */ /** * Generated data model types. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type { DataModelFromSchemaDefinition, DocumentByName, TableNamesInDataModel, SystemTableNames, } from "convex/server"; import type { GenericId } from "convex/values"; import schema from "../schema.js"; /** * The names of all of your Convex tables. */ export type TableNames = TableNamesInDataModel; /** * The type of a document stored in Convex. * * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Doc = DocumentByName< DataModel, TableName >; /** * An identifier for a document in Convex. * * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. * * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Id = GenericId; /** * A type describing your Convex data model. * * This type includes information about what tables you have, the type of * documents stored in those tables, and the indexes defined on them. * * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ export type DataModel = DataModelFromSchemaDefinition; ================================================ FILE: convex/_generated/server.d.ts ================================================ /* eslint-disable */ /** * Generated utilities for implementing server-side Convex query and mutation functions. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import { ActionBuilder, HttpActionBuilder, MutationBuilder, QueryBuilder, GenericActionCtx, GenericMutationCtx, GenericQueryCtx, GenericDatabaseReader, GenericDatabaseWriter, } from "convex/server"; import type { DataModel } from "./dataModel.js"; /** * Define a query in this Convex app's public API. * * This function will be allowed to read your Convex database and will be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export declare const query: QueryBuilder; /** * Define a query that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to read from your Convex database. It will not be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export declare const internalQuery: QueryBuilder; /** * Define a mutation in this Convex app's public API. * * This function will be allowed to modify your Convex database and will be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export declare const mutation: MutationBuilder; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to modify your Convex database. It will not be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export declare const internalMutation: MutationBuilder; /** * Define an action in this Convex app's public API. * * An action is a function which can execute any JavaScript code, including non-deterministic * code and code with side-effects, like calling third-party services. * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. * * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ export declare const action: ActionBuilder; /** * Define an action that is only accessible from other Convex functions (but not from the client). * * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ export declare const internalAction: ActionBuilder; /** * Define an HTTP action. * * The wrapped function will be used to respond to HTTP requests received * by a Convex deployment if the requests matches the path and method where * this action is routed. Be sure to route your httpAction in `convex/http.js`. * * @param func - The function. It receives an {@link ActionCtx} as its first argument * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export declare const httpAction: HttpActionBuilder; /** * A set of services for use within Convex query functions. * * The query context is passed as the first argument to any Convex query * function run on the server. * * This differs from the {@link MutationCtx} because all of the services are * read-only. */ export type QueryCtx = GenericQueryCtx; /** * A set of services for use within Convex mutation functions. * * The mutation context is passed as the first argument to any Convex mutation * function run on the server. */ export type MutationCtx = GenericMutationCtx; /** * A set of services for use within Convex action functions. * * The action context is passed as the first argument to any Convex action * function run on the server. */ export type ActionCtx = GenericActionCtx; /** * An interface to read from the database within Convex query functions. * * The two entry points are {@link DatabaseReader.get}, which fetches a single * document by its {@link Id}, or {@link DatabaseReader.query}, which starts * building a query. */ export type DatabaseReader = GenericDatabaseReader; /** * An interface to read from and write to the database within Convex mutation * functions. * * Convex guarantees that all writes within a single mutation are * executed atomically, so you never have to worry about partial writes leaving * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) * for the guarantees Convex provides your functions. */ export type DatabaseWriter = GenericDatabaseWriter; ================================================ FILE: convex/_generated/server.js ================================================ /* eslint-disable */ /** * Generated utilities for implementing server-side Convex query and mutation functions. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import { actionGeneric, httpActionGeneric, queryGeneric, mutationGeneric, internalActionGeneric, internalMutationGeneric, internalQueryGeneric, } from "convex/server"; /** * Define a query in this Convex app's public API. * * This function will be allowed to read your Convex database and will be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export const query = queryGeneric; /** * Define a query that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to read from your Convex database. It will not be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export const internalQuery = internalQueryGeneric; /** * Define a mutation in this Convex app's public API. * * This function will be allowed to modify your Convex database and will be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export const mutation = mutationGeneric; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to modify your Convex database. It will not be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export const internalMutation = internalMutationGeneric; /** * Define an action in this Convex app's public API. * * An action is a function which can execute any JavaScript code, including non-deterministic * code and code with side-effects, like calling third-party services. * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. * * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ export const action = actionGeneric; /** * Define an action that is only accessible from other Convex functions (but not from the client). * * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ export const internalAction = internalActionGeneric; /** * Define an HTTP action. * * The wrapped function will be used to respond to HTTP requests received * by a Convex deployment if the requests matches the path and method where * this action is routed. Be sure to route your httpAction in `convex/http.js`. * * @param func - The function. It receives an {@link ActionCtx} as its first argument * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction = httpActionGeneric; ================================================ FILE: convex/account.ts ================================================ import { components } from "./_generated/api"; import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireSessionLegacyUserId } from "./lib/authorization"; export const deleteCurrentUser = mutation({ args: {}, returns: v.null(), handler: async (ctx) => { const legacyUserId = await requireSessionLegacyUserId(ctx); const user = (await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "userId", operator: "eq", value: String(legacyUserId) }], })) as { _id?: string | null } | null; if (!user?._id) { throw new Error("Account not found."); } await ctx.runMutation(components.betterAuth.adapter.deleteOne, { input: { model: "user", where: [{ field: "_id", value: user._id }], }, }); return null; }, }); ================================================ FILE: convex/adminData.ts ================================================ import { mutation, query, type MutationCtx, type QueryCtx, } from "./_generated/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { components } from "./_generated/api"; type AuthCtx = MutationCtx | QueryCtx; async function isAdmin(ctx: AuthCtx) { const identity = (await ctx.auth.getUserIdentity()) as { role?: string | null; } | null; return identity?.role === "admin"; } const adminLanguageValidator = v.object({ id: v.number(), name: v.string(), short: v.string(), flag: v.number(), flag_file: v.string(), speaker: v.string(), default_text: v.string(), tts_replace: v.string(), public: v.boolean(), rtl: v.boolean(), }); const adminCourseValidator = v.object({ id: v.number(), learning_language: v.number(), from_language: v.number(), public: v.boolean(), official: v.boolean(), name: v.union(v.string(), v.null()), about: v.union(v.string(), v.null()), conlang: v.boolean(), short: v.union(v.string(), v.null()), tags: v.array(v.string()), }); const adminApprovalValidator = v.object({ id: v.number(), date: v.number(), name: v.string(), }); const adminStoryValidator = v.object({ id: v.number(), name: v.string(), image: v.string(), public: v.boolean(), short: v.string(), approvals: v.array(adminApprovalValidator), }); const yesNoAllFilterValidator = v.union( v.literal("all"), v.literal("yes"), v.literal("no"), ); const roleFilterValidator = v.union( v.literal("all"), v.literal("user"), v.literal("contributor"), v.literal("admin"), ); const adminUserValidator = v.object({ rowKey: v.string(), id: v.number(), name: v.string(), email: v.string(), image: v.union(v.string(), v.null()), regdate: v.union(v.number(), v.null()), activated: v.boolean(), role: v.boolean(), admin: v.boolean(), discordLinked: v.boolean(), discordAccountId: v.union(v.string(), v.null()), discordStoriesRole: v.union(v.string(), v.null()), discordStoriesSyncStatus: v.union( v.literal("assigned"), v.literal("up_to_date"), v.literal("no_milestone"), v.literal("not_linked"), v.literal("member_not_found"), v.literal("error"), v.null(), ), discordStoriesLastSyncedAt: v.union(v.number(), v.null()), }); async function findAuthUserByLegacyId(ctx: AuthCtx, legacyId: number) { return (await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "userId", operator: "eq", value: String(legacyId) }], })) as { _id: string; userId?: string | null; name?: string | null; email?: string | null; image?: string | null; createdAt?: number | null; role?: string | null; emailVerified?: boolean | null; } | null; } function toAdminUser( user: { _id?: string; userId?: string | null; name?: string | null; email?: string | null; image?: string | null; createdAt?: number | null; role?: string | null; emailVerified?: unknown; }, discordAccountId?: string | null, storiesRoleSnapshot?: { assignedStoriesCount?: number | null; syncStatus?: | "assigned" | "up_to_date" | "no_milestone" | "not_linked" | "member_not_found" | "error" | null; lastSyncedAt?: number | null; } | null, ) { const numericId = Number.parseInt(user.userId ?? "", 10); const role = user.role ?? null; const assignedStoriesCount = typeof storiesRoleSnapshot?.assignedStoriesCount === "number" ? storiesRoleSnapshot.assignedStoriesCount : null; return { rowKey: user._id ?? `${user.userId ?? ""}-${user.email ?? ""}-${user.createdAt ?? 0}`, id: Number.isFinite(numericId) ? numericId : 0, name: user.name ?? "", email: user.email ?? "", image: typeof user.image === "string" && user.image.length > 0 ? user.image : null, regdate: typeof user.createdAt === "number" ? user.createdAt : null, activated: Boolean(user.emailVerified), role: role === "contributor" || role === "admin", admin: role === "admin", discordLinked: typeof discordAccountId === "string" && discordAccountId.length > 0, discordAccountId: typeof discordAccountId === "string" && discordAccountId.length > 0 ? discordAccountId : null, discordStoriesRole: typeof assignedStoriesCount === "number" && assignedStoriesCount > 0 ? `${assignedStoriesCount} Stories` : null, discordStoriesSyncStatus: storiesRoleSnapshot?.syncStatus ?? null, discordStoriesLastSyncedAt: typeof storiesRoleSnapshot?.lastSyncedAt === "number" ? storiesRoleSnapshot.lastSyncedAt : null, }; } async function getDiscordAccountIdsByAuthUserIds( ctx: AuthCtx, authUserIds: string[], ) { const uniqueAuthUserIds = Array.from( new Set(authUserIds.filter((value) => value.length > 0)), ); if (uniqueAuthUserIds.length === 0) return new Map(); const response = await ctx.runQuery(components.betterAuth.adapter.findMany, { model: "account", where: [ { field: "providerId", operator: "eq", value: "discord" }, { field: "userId", operator: "in", value: uniqueAuthUserIds }, ], paginationOpts: { cursor: null, numItems: uniqueAuthUserIds.length + 20 }, }); const discordAccountIdByAuthUserId = new Map(); for (const account of response.page as Array<{ userId?: string | null; accountId?: string | null; }>) { if (!account.userId || !account.accountId) continue; discordAccountIdByAuthUserId.set(account.userId, account.accountId); } return discordAccountIdByAuthUserId; } async function getStoriesRoleSnapshotsByLegacyUserIds( ctx: AuthCtx, legacyUserIds: number[], ) { const wantedIds = new Set( legacyUserIds.filter((legacyUserId) => Number.isFinite(legacyUserId)), ); if (wantedIds.size === 0) { return new Map< number, { assignedStoriesCount?: number | null; syncStatus?: | "assigned" | "up_to_date" | "no_milestone" | "not_linked" | "member_not_found" | "error" | null; lastSyncedAt?: number | null; } >(); } const snapshotByLegacyUserId = new Map< number, { assignedStoriesCount?: number | null; syncStatus?: | "assigned" | "up_to_date" | "no_milestone" | "not_linked" | "member_not_found" | "error" | null; lastSyncedAt?: number | null; } >(); const rows = await Promise.all( Array.from(wantedIds).map((legacyUserId) => ctx.db .query("discord_stories_role_sync") .withIndex("by_legacy_user_id", (q) => q.eq("legacyUserId", legacyUserId), ) .unique(), ), ); for (const row of rows) { if (!row) continue; if (!wantedIds.has(row.legacyUserId)) continue; snapshotByLegacyUserId.set(row.legacyUserId, row); } return snapshotByLegacyUserId; } export const getAdminUsersPage = query({ args: { query: v.string(), limit: v.number(), activatedFilter: yesNoAllFilterValidator, roleFilter: roleFilterValidator, }, returns: v.object({ users: v.array(adminUserValidator), hasMore: v.boolean(), }), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) { return { users: [], hasMore: false }; } const limit = Math.max(1, Math.min(500, Math.floor(args.limit))); const queryLimit = limit + 1; const searchTerm = args.query.trim(); const searchLower = searchTerm.toLowerCase(); const searchMode = searchTerm.length === 0 ? "none" : /^\d+$/.test(searchLower) ? "id" : searchTerm.includes("@") ? "email" : "username"; const matchedUsers = searchMode === "id" ? await ctx.runQuery(components.betterAuth.adapter.searchUsersById, { activatedFilter: args.activatedFilter, roleFilter: args.roleFilter, id: searchTerm, }) : searchMode === "email" ? await ctx.runQuery( components.betterAuth.adapter.searchUsersByEmailPrefix, { activatedFilter: args.activatedFilter, roleFilter: args.roleFilter, prefix: searchTerm, limit: queryLimit, }, ) : searchMode === "username" ? await ctx.runQuery( components.betterAuth.adapter.searchUsersByUsernamePrefix, { activatedFilter: args.activatedFilter, roleFilter: args.roleFilter, prefix: searchTerm, limit: queryLimit, }, ) : await ctx.runQuery(components.betterAuth.adapter.searchUsersAll, { activatedFilter: args.activatedFilter, roleFilter: args.roleFilter, limit: queryLimit, }); const pageUsers = matchedUsers.slice(0, limit) as Array<{ _id?: string; userId?: string | null; name?: string | null; email?: string | null; image?: string | null; createdAt?: number | null; role?: string | null; emailVerified?: unknown; }>; const discordAccountIdByAuthUserId = await getDiscordAccountIdsByAuthUserIds( ctx, pageUsers .map((user) => user._id) .filter((value): value is string => typeof value === "string"), ); const storiesRoleSnapshotByLegacyUserId = await getStoriesRoleSnapshotsByLegacyUserIds( ctx, pageUsers .map((user) => Number.parseInt(user.userId ?? "", 10)) .filter((value) => Number.isFinite(value)), ); const users = pageUsers.map((user) => toAdminUser( user, typeof user._id === "string" ? (discordAccountIdByAuthUserId.get(user._id) ?? null) : null, (() => { const legacyUserId = Number.parseInt(user.userId ?? "", 10); return Number.isFinite(legacyUserId) ? (storiesRoleSnapshotByLegacyUserId.get(legacyUserId) ?? null) : null; })(), ), ); return { users, hasMore: matchedUsers.length > limit }; }, }); export const getAdminUserByLegacyId = query({ args: { id: v.number(), }, returns: v.union(adminUserValidator, v.null()), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) return null; const user = await findAuthUserByLegacyId(ctx, args.id); if (!user?._id) return null; const discordAccountIdByAuthUserId = await getDiscordAccountIdsByAuthUserIds(ctx, [user._id]); const storiesRoleSnapshotByLegacyUserId = await getStoriesRoleSnapshotsByLegacyUserIds(ctx, [args.id]); return toAdminUser( user, discordAccountIdByAuthUserId.get(user._id) ?? null, storiesRoleSnapshotByLegacyUserId.get(args.id) ?? null, ); }, }); export const setAdminUserActivated = mutation({ args: { id: v.number(), activated: v.boolean(), }, returns: v.null(), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) return null; const user = await findAuthUserByLegacyId(ctx, args.id); if (!user?._id) return null; await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: user._id }], update: { emailVerified: args.activated }, }, }); return null; }, }); export const setAdminUserWrite = mutation({ args: { id: v.number(), write: v.boolean(), }, returns: v.null(), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) return null; const user = await findAuthUserByLegacyId(ctx, args.id); if (!user?._id) return null; await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: user._id }], update: { role: args.write ? "contributor" : "user" }, }, }); return null; }, }); export const setAdminUserDelete = mutation({ args: { id: v.number(), }, returns: v.null(), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) return null; const user = await findAuthUserByLegacyId(ctx, args.id); if (!user?._id) return null; await ctx.runMutation(components.betterAuth.adapter.deleteOne, { input: { model: "user", where: [{ field: "_id", value: user._id }], }, }); return null; }, }); export const getAdminLanguages = query({ args: {}, returns: v.array(adminLanguageValidator), handler: async (ctx) => { if (!(await isAdmin(ctx))) return []; const rows = await ctx.db.query("languages").collect(); return rows .map((row) => ({ id: row.legacyId, name: row.name, short: row.short, flag: typeof row.flag === "number" ? row.flag : Number.isFinite(Number(row.flag)) ? Number(row.flag) : 0, flag_file: row.flag_file ?? "", speaker: row.speaker ?? "", default_text: row.default_text ?? "", tts_replace: row.tts_replace ?? "", public: row.public, rtl: row.rtl, })) .sort((a, b) => a.id - b.id); }, }); export const getAdminCourses = query({ args: {}, returns: v.object({ courses: v.array(adminCourseValidator), languages: v.array(adminLanguageValidator), }), handler: async (ctx) => { if (!(await isAdmin(ctx))) { return { courses: [], languages: [], }; } const [courseRows, languageRows] = await Promise.all([ ctx.db.query("courses").collect(), ctx.db.query("languages").collect(), ]); const languageIdToLegacy = new Map, number>(); for (const language of languageRows) { languageIdToLegacy.set(language._id, language.legacyId); } const courses = courseRows .map((row) => ({ id: row.legacyId, learning_language: languageIdToLegacy.get(row.learningLanguageId) ?? 0, from_language: languageIdToLegacy.get(row.fromLanguageId) ?? 0, public: row.public, official: row.official, name: row.name ?? null, about: row.about ?? null, conlang: row.conlang ?? false, short: row.short ?? null, tags: row.tags ?? [], })) .sort((a, b) => a.id - b.id); const languages = languageRows .map((row) => ({ id: row.legacyId, name: row.name, short: row.short, flag: typeof row.flag === "number" ? row.flag : Number.isFinite(Number(row.flag)) ? Number(row.flag) : 0, flag_file: row.flag_file ?? "", speaker: row.speaker ?? "", default_text: row.default_text ?? "", tts_replace: row.tts_replace ?? "", public: row.public, rtl: row.rtl, })) .sort((a, b) => a.id - b.id); return { courses, languages }; }, }); export const getAdminStoryByLegacyId = query({ args: { legacyStoryId: v.number(), }, returns: v.union(adminStoryValidator, v.null()), handler: async (ctx, args) => { if (!(await isAdmin(ctx))) return null; const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story || typeof story.legacyId !== "number") return null; const course = await ctx.db.get(story.courseId); if (!course || !course.short) return null; const approvals = await ctx.db .query("story_approval") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .collect(); const legacyIds = approvals .map((approval) => approval.legacyUserId) .filter((id): id is number => typeof id === "number"); const authUsers = await ctx.runQuery( components.betterAuth.adapter.findMany, { model: "user", where: [ { field: "userId", operator: "in", value: legacyIds.map(String) }, ], paginationOpts: { cursor: null, numItems: legacyIds.length + 10 }, }, ); const userNameByLegacyId = new Map(); for (const user of authUsers.page as Array<{ userId?: string | null; name?: string | null; }>) { const legacyId = Number.parseInt(user.userId ?? "", 10); if (!Number.isFinite(legacyId) || !user.name) continue; userNameByLegacyId.set(legacyId, user.name); } return { id: story.legacyId, name: story.name, image: story.imageId ? ((await ctx.db.get(story.imageId))?.legacyId ?? "") : "", public: story.public, short: course.short, approvals: approvals .map((approval) => ({ id: approval.legacyId ?? 0, date: approval.date, name: typeof approval.legacyUserId === "number" ? (userNameByLegacyId.get(approval.legacyUserId) ?? "Unknown") : "Unknown", })) .filter((approval) => approval.id > 0), }; }, }); ================================================ FILE: convex/adminStoryWrite.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireAdmin } from "./lib/authorization"; import { recomputeCoursePublishedCount } from "./lib/courseCounts"; export const togglePublished = mutation({ args: { legacyStoryId: v.number(), operationKey: v.optional(v.string()), }, returns: v.null(), handler: async (ctx, args) => { await requireAdmin(ctx); const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story || typeof story.legacyId !== "number") { throw new Error(`Story ${args.legacyStoryId} not found`); } const course = await ctx.db.get(story.courseId); if (!course || typeof course.legacyId !== "number") { throw new Error(`Course missing for story ${args.legacyStoryId}`); } const nextPublic = !story.public; await ctx.db.patch(story._id, { public: nextPublic }); await recomputeCoursePublishedCount(ctx, course._id); return null; }, }); export const removeApproval = mutation({ args: { legacyStoryId: v.number(), legacyApprovalId: v.number(), operationKey: v.optional(v.string()), }, returns: v.null(), handler: async (ctx, args) => { await requireAdmin(ctx); const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story || typeof story.legacyId !== "number") { throw new Error(`Story ${args.legacyStoryId} not found`); } const approval = await ctx.db .query("story_approval") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyApprovalId)) .unique(); if (!approval) { return null; } if (approval.storyId !== story._id) { throw new Error("Approval does not belong to story"); } await ctx.db.delete(approval._id); const approvals = await ctx.db .query("story_approval") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .collect(); const approvalCount = approvals.length; const storyStatus: "draft" | "feedback" | "finished" = approvalCount === 0 ? "draft" : approvalCount === 1 ? "feedback" : "finished"; await ctx.db.patch(story._id, { status: storyStatus, approvalCount, }); return null; }, }); ================================================ FILE: convex/adminWrite.ts ================================================ import { mutation } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; import { v } from "convex/values"; import { requireAdmin } from "./lib/authorization"; function toLegacyLanguageResponse(row: { legacyId: number; name: string; short: string; flag?: number | string; flag_file?: string; speaker?: string; default_text?: string; tts_replace?: string; public: boolean; rtl: boolean; }) { return { id: row.legacyId, name: row.name, short: row.short, flag: typeof row.flag === "number" ? row.flag : Number.isFinite(Number(row.flag)) ? Number(row.flag) : 0, flag_file: row.flag_file ?? "", speaker: row.speaker ?? "", default_text: row.default_text ?? "", tts_replace: row.tts_replace ?? "", public: row.public, rtl: row.rtl, }; } async function getNextLegacyId( ctx: MutationCtx, table: "languages" | "courses", ) { const rows = await ctx.db.query(table).collect(); const current = rows.reduce((max, row) => { const legacyId = Number(row.legacyId ?? 0); return legacyId > max ? legacyId : max; }, 0); return Math.max(1, current + 1); } async function getNextUnusedLegacyId( ctx: MutationCtx, table: "languages" | "courses", ) { let candidate = await getNextLegacyId(ctx, table); for (let attempts = 0; attempts < 1000; attempts += 1) { const existing = await ctx.db .query(table) .withIndex("by_id_value", (q) => q.eq("legacyId", candidate)) .unique(); if (!existing) return candidate; candidate += 1; } throw new Error(`Failed to allocate unique ${table} legacy ID`); } async function getLanguageByLegacyId(ctx: MutationCtx, legacyId: number) { return await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", legacyId)) .unique(); } export const updateAdminLanguage = mutation({ args: { id: v.number(), name: v.string(), short: v.string(), flag: v.number(), flag_file: v.string(), speaker: v.string(), rtl: v.boolean(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), name: v.string(), short: v.string(), flag: v.number(), flag_file: v.string(), speaker: v.string(), default_text: v.string(), tts_replace: v.string(), public: v.boolean(), rtl: v.boolean(), }), handler: async (ctx, args) => { await requireAdmin(ctx); const language = await getLanguageByLegacyId(ctx, args.id); if (!language) { throw new Error(`Language ${args.id} not found`); } const operationKey = args.operationKey ?? `language:${args.id}:admin_set:${Date.now()}`; await ctx.db.patch(language._id, { name: args.name, short: args.short, flag: args.flag, flag_file: args.flag_file, speaker: args.speaker, rtl: args.rtl, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); return toLegacyLanguageResponse({ ...language, name: args.name, short: args.short, flag: args.flag, flag_file: args.flag_file, speaker: args.speaker, rtl: args.rtl, }); }, }); export const createAdminLanguage = mutation({ args: { name: v.string(), short: v.string(), flag: v.number(), flag_file: v.string(), speaker: v.string(), rtl: v.boolean(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), name: v.string(), short: v.string(), flag: v.number(), flag_file: v.string(), speaker: v.string(), default_text: v.string(), tts_replace: v.string(), public: v.boolean(), rtl: v.boolean(), }), handler: async (ctx, args) => { await requireAdmin(ctx); const legacyId = await getNextUnusedLegacyId(ctx, "languages"); const operationKey = args.operationKey ?? `language:${legacyId}:admin_create:${Date.now()}`; await ctx.db.insert("languages", { legacyId, name: args.name, short: args.short, flag: args.flag, flag_file: args.flag_file, speaker: args.speaker, default_text: "", tts_replace: "", public: false, rtl: args.rtl, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); return { id: legacyId, name: args.name, short: args.short, flag: args.flag, flag_file: args.flag_file, speaker: args.speaker, default_text: "", tts_replace: "", public: false, rtl: args.rtl, }; }, }); export const updateAdminCourse = mutation({ args: { id: v.number(), learning_language: v.number(), from_language: v.number(), public: v.optional(v.boolean()), name: v.optional(v.string()), conlang: v.optional(v.boolean()), tags: v.optional(v.array(v.string())), about: v.optional(v.string()), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), learning_language: v.number(), from_language: v.number(), public: v.boolean(), official: v.boolean(), name: v.union(v.string(), v.null()), about: v.union(v.string(), v.null()), conlang: v.boolean(), short: v.union(v.string(), v.null()), tags: v.array(v.string()), }), handler: async (ctx, args) => { await requireAdmin(ctx); const [course, learningLanguage, fromLanguage] = await Promise.all([ ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.id)) .unique(), getLanguageByLegacyId(ctx, args.learning_language), getLanguageByLegacyId(ctx, args.from_language), ]); if (!course) throw new Error(`Course ${args.id} not found`); if (!learningLanguage || !fromLanguage) { throw new Error("Course languages not found"); } const short = `${learningLanguage.short}-${fromLanguage.short}`; const operationKey = args.operationKey ?? `course:${args.id}:admin_set:${Date.now()}`; const nextPublic = args.public ?? course.public; const nextName = args.name ?? course.name ?? null; const nextConlang = args.conlang ?? course.conlang ?? false; const nextTags = args.tags ?? course.tags ?? []; const nextAbout = args.about ?? course.about ?? null; const patchData: { learningLanguageId: typeof learningLanguage._id; fromLanguageId: typeof fromLanguage._id; learning_language_name: string; from_language_name: string; short: string; public: boolean; mirrorUpdatedAt: number; lastOperationKey: string; name?: string; conlang?: boolean; tags?: string[]; about?: string; } = { learningLanguageId: learningLanguage._id, fromLanguageId: fromLanguage._id, learning_language_name: learningLanguage.name, from_language_name: fromLanguage.name, short, public: nextPublic, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }; if (nextName !== null) patchData.name = nextName; if (nextAbout !== null) patchData.about = nextAbout; patchData.conlang = nextConlang; patchData.tags = nextTags; await ctx.db.patch(course._id, patchData); return { id: args.id, learning_language: args.learning_language, from_language: args.from_language, public: nextPublic, official: course.official, name: nextName, about: nextAbout, conlang: nextConlang, short, tags: nextTags, }; }, }); export const createAdminCourse = mutation({ args: { learning_language: v.number(), from_language: v.number(), public: v.optional(v.boolean()), name: v.optional(v.string()), official: v.optional(v.number()), conlang: v.optional(v.boolean()), tags: v.optional(v.array(v.string())), about: v.optional(v.string()), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), learning_language: v.number(), from_language: v.number(), public: v.boolean(), official: v.boolean(), name: v.union(v.string(), v.null()), about: v.union(v.string(), v.null()), conlang: v.boolean(), short: v.union(v.string(), v.null()), tags: v.array(v.string()), }), handler: async (ctx, args) => { await requireAdmin(ctx); const [learningLanguage, fromLanguage] = await Promise.all([ getLanguageByLegacyId(ctx, args.learning_language), getLanguageByLegacyId(ctx, args.from_language), ]); if (!learningLanguage || !fromLanguage) { throw new Error("Course languages not found"); } const legacyId = await getNextUnusedLegacyId(ctx, "courses"); const short = `${learningLanguage.short}-${fromLanguage.short}`; const nextPublic = args.public ?? false; const nextOfficial = args.official ?? 0; const nextName = args.name ?? null; const nextConlang = args.conlang ?? false; const nextTags = args.tags ?? []; const nextAbout = args.about ?? null; const operationKey = args.operationKey ?? `course:${legacyId}:admin_create:${Date.now()}`; await ctx.db.insert("courses", { legacyId, short, learningLanguageId: learningLanguage._id, fromLanguageId: fromLanguage._id, public: nextPublic, official: nextOfficial !== 0, name: nextName ?? undefined, about: nextAbout ?? undefined, conlang: nextConlang, tags: nextTags, learning_language_name: learningLanguage.name, from_language_name: fromLanguage.name, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); return { id: legacyId, learning_language: args.learning_language, from_language: args.from_language, public: nextPublic, official: nextOfficial !== 0, name: nextName, about: nextAbout, conlang: nextConlang, short, tags: nextTags, }; }, }); ================================================ FILE: convex/audioRead.ts ================================================ import { query } from "./_generated/server"; import { v } from "convex/values"; export const getSpeakerByName = query({ args: { speaker: v.string(), }, returns: v.union( v.object({ id: v.number(), speaker: v.string(), type: v.string(), gender: v.optional(v.string()), service: v.optional(v.string()), }), v.null(), ), handler: async (ctx, args) => { const row = await ctx.db .query("speakers") .withIndex("by_speaker", (q) => q.eq("speaker", args.speaker)) .unique(); if (!row) return null; return { id: row.legacyId ?? 0, speaker: row.speaker, type: row.type, gender: row.gender, service: row.service, }; }, }); ================================================ FILE: convex/auth.config.ts ================================================ import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config"; import type { AuthConfig } from "convex/server"; export default { providers: [getAuthConfigProvider()], } satisfies AuthConfig; ================================================ FILE: convex/auth.ts ================================================ import { authComponent } from "./betterAuth/auth"; import { query } from "./_generated/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; import { requireContributorOrAdmin } from "./lib/authorization"; const authClientApi = authComponent.clientApi(); export const getAuthUser = authClientApi.getAuthUser; export const getCurrentUser = query({ args: {}, handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) return null; return { ...identity, role: identity.role ?? "user", }; }, }); export const getLinkedProvidersForCurrentUser = query({ args: {}, handler: async (ctx) => { const identity = (await ctx.auth.getUserIdentity()) as { email?: string | null; } | null; const email = identity?.email?.toLowerCase(); if (!email) return [] as string[]; const authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "email", value: email }], }); if (!authUser?._id) return [] as string[]; const accounts = await ctx.runQuery( components.betterAuth.adapter.findMany, { model: "account", where: [{ field: "userId", value: authUser._id }], paginationOpts: { cursor: null, numItems: 100 }, }, ); const providers = (accounts.page as Array<{ providerId?: string | null }>) .map((account) => account.providerId) .filter((provider): provider is string => Boolean(provider)); return Array.from(new Set(providers)); }, }); export const getUserNamesByLegacyIds = query({ args: { legacyIds: v.array(v.number()), }, handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const uniqueIds = Array.from(new Set(args.legacyIds)); if (!uniqueIds.length) return [] as Array<{ legacyId: number; name: string }>; const userIds = uniqueIds.map((id) => String(id)); const users = await ctx.runQuery(components.betterAuth.adapter.findMany, { model: "user", where: [{ field: "userId", operator: "in", value: userIds }], paginationOpts: { cursor: null, numItems: userIds.length + 10 }, }); return ( users.page as Array<{ userId?: string | null; name?: string | null }> ) .map((user) => { const legacyId = Number.parseInt(user.userId ?? "", 10); if (!Number.isFinite(legacyId) || !user.name) return null; return { legacyId, name: user.name }; }) .filter((row): row is { legacyId: number; name: string } => row !== null); }, }); ================================================ FILE: convex/authFunctions.ts ================================================ import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; export const onCreate = internalMutation({ args: { model: v.string(), doc: v.any(), }, handler: async (ctx, args) => { if (args.model !== "user") return; const doc = args.doc as { _id: string; userId?: string | null }; if (doc.userId) return; let maxId = 0; let cursor: string | null = null; do { const page = (await ctx.runQuery( components.betterAuth.adapter.findMany as any, { model: "user", where: [], paginationOpts: { cursor, numItems: 1000 }, }, )) as any; for (const user of page.page as Array<{ userId?: string | null }>) { const parsed = Number.parseInt(user.userId ?? "", 10); if (!Number.isNaN(parsed) && parsed > maxId) maxId = parsed; } cursor = page.isDone ? null : (page.continueCursor ?? null); } while (cursor); const nextId = maxId + 1; await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: doc._id }], update: { userId: String(nextId) }, }, }); }, }); ================================================ FILE: convex/authMigration.ts ================================================ import { action, query } from "./_generated/server"; import { v } from "convex/values"; import { api, components } from "./_generated/api"; const PAGE_SIZE = 200; export const listLegacyUsersForRoleSync = query({ args: { cursor: v.optional(v.string()), limit: v.optional(v.number()), }, handler: async () => { // Legacy users table has been removed from app schema. return { page: [], isDone: true, continueCursor: null, }; }, }); export const syncBetterAuthRoles = action({ args: { dryRun: v.optional(v.boolean()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const dryRun = args.dryRun ?? false; const limit = args.limit ?? PAGE_SIZE; let cursor: string | undefined = undefined; let total = 0; let updated = 0; let skipped = 0; let missing = 0; while (true) { const pageResult = (await ctx.runQuery( api.authMigration.listLegacyUsersForRoleSync, { cursor: cursor ?? undefined, limit }, )) as { page: Array<{ email?: string; admin?: boolean; role?: boolean }>; isDone: boolean; continueCursor?: string | null; }; for (const legacyUser of pageResult.page) { total += 1; if (!legacyUser.email) { skipped += 1; continue; } let desiredRole: string | null = null; if (legacyUser.admin) { desiredRole = "admin"; } else if (legacyUser.role) { desiredRole = "contributor"; } if (!desiredRole) { skipped += 1; continue; } const authUser = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "user", where: [ { field: "email", value: legacyUser.email.toLowerCase(), }, ], }, ); if (!authUser) { missing += 1; continue; } const authRole = Array.isArray(authUser.role) ? authUser.role[0] : authUser.role; if (authRole === desiredRole) { skipped += 1; continue; } if (!dryRun) { await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [ { field: "_id", value: (authUser as { _id: string })._id, }, ], update: { role: desiredRole } as any, }, }); } updated += 1; } if (pageResult.isDone) { break; } cursor = pageResult.continueCursor ?? undefined; } return { dryRun, total, updated, skipped, missing, }; }, }); function normalizeUsername(input: string): string { const lower = input.trim().toLowerCase(); const cleaned = lower.replace(/[^a-z0-9_-]/g, "_"); if (cleaned.length >= 3) return cleaned; return `${cleaned || "user"}_${Math.random().toString(36).slice(2, 6)}`; } export const migrateLegacyUsersToBetterAuth = action({ args: { dryRun: v.optional(v.boolean()), limit: v.optional(v.number()), cursor: v.optional(v.string()), }, handler: async (ctx, args) => { const dryRun = args.dryRun ?? false; const limit = args.limit ?? PAGE_SIZE; const cursor = args.cursor ?? undefined; let total = 0; let created = 0; let skipped = 0; let missingPassword = 0; let accountsCreated = 0; let accountsFailed = 0; const errors: Array<{ email?: string; legacyId?: number; step: string; message: string; }> = []; const pageResult = (await ctx.runQuery( api.authMigration.listLegacyUsersForRoleSync, { cursor, limit }, )) as { page: Array<{ legacyId?: number; name?: string; email?: string; image?: string; emailVerified?: number; password?: string; regdate?: number; }>; isDone: boolean; continueCursor?: string | null; }; for (const legacyUser of pageResult.page) { total += 1; if (!legacyUser.email) { skipped += 1; continue; } const email = legacyUser.email.toLowerCase(); const existing = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "user", where: [ { field: "email", value: email, }, ], }, ); if (existing) { skipped += 1; continue; } const createdAt = (() => { if (typeof legacyUser.regdate !== "number") { return Date.now(); } return legacyUser.regdate > 1_000_000_000_000 ? legacyUser.regdate : legacyUser.regdate * 1000; })(); const displayUsername = legacyUser.name || email.split("@")[0] || email; const baseUsername = normalizeUsername(displayUsername); const suffix = legacyUser.legacyId ? `_${legacyUser.legacyId}` : `_${Math.random().toString(36).slice(2, 10)}`; const username = `${baseUsername}${suffix}`; if (!dryRun) { let newUser: { _id: string } | null | undefined = undefined; try { newUser = await ctx.runMutation( components.betterAuth.adapter.create, { input: { model: "user", data: { createdAt, updatedAt: createdAt, email, emailVerified: Boolean(legacyUser.emailVerified), name: displayUsername, image: legacyUser.image ?? null, username, displayUsername, }, }, }, ); } catch (error: any) { errors.push({ email, legacyId: legacyUser.legacyId, step: "createUser", message: String(error?.message || error), }); } const newUserId = (newUser as { _id?: string } | null | undefined)?._id ?? ( await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "email", value: email }], }) )?._id; if (!newUserId) { skipped += 1; continue; } if (legacyUser.password) { try { await ctx.runMutation(components.betterAuth.adapter.create, { input: { model: "account", data: { accountId: newUserId, providerId: "credential", password: legacyUser.password, userId: newUserId, createdAt, updatedAt: createdAt, }, }, }); } catch (error: any) { errors.push({ email, legacyId: legacyUser.legacyId, step: "createAccount", message: String(error?.message || error), }); } } else { missingPassword += 1; } } else if (!legacyUser.password) { missingPassword += 1; } created += 1; } return { dryRun, total, created, skipped, missingPassword, errors, continueCursor: pageResult.continueCursor ?? undefined, isDone: pageResult.isDone, }; }, }); const betterAuthUserInput = v.object({ legacyId: v.number(), email: v.string(), name: v.string(), username: v.string(), displayUsername: v.string(), image: v.optional(v.union(v.null(), v.string())), emailVerified: v.optional(v.boolean()), createdAt: v.number(), password: v.optional(v.string()), }); const betterAuthAccountInput = v.object({ legacyAccountId: v.number(), legacyUserId: v.number(), providerId: v.string(), providerAccountId: v.optional(v.union(v.null(), v.string())), accessToken: v.optional(v.string()), refreshToken: v.optional(v.string()), expiresAt: v.optional(v.number()), tokenType: v.optional(v.string()), scope: v.optional(v.string()), idToken: v.optional(v.string()), sessionState: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), accountType: v.optional(v.string()), }); export const importBetterAuthUsersBatch = action({ args: { users: v.array(betterAuthUserInput), verbose: v.optional(v.boolean()), fastPath: v.optional(v.boolean()), }, handler: async (ctx, args) => { const users = args.users; const verbose = args.verbose ?? false; const fastPath = args.fastPath ?? false; if (verbose) { console.log( `[importBetterAuthUsersBatch] received ${users.length} users`, users[0]?.email ? `first=${users[0].email}` : "", ); } let created = 0; let skipped = 0; let missingPassword = 0; let accountsCreated = 0; let accountsFailed = 0; const errors: Array<{ email: string; legacyId: number; step: string; message: string; }> = []; const existingEmails = new Set(); for (const user of users) { const email = user.email.toLowerCase(); if (existingEmails.has(email)) { skipped += 1; continue; } existingEmails.add(email); let newUserId: string | undefined; let didCreate = false; try { const newUser = await ctx.runMutation( components.betterAuth.adapter.create, { input: { model: "user", data: { createdAt: user.createdAt, updatedAt: user.createdAt, email, emailVerified: Boolean(user.emailVerified), name: user.name, image: user.image ?? null, username: user.username, displayUsername: user.displayUsername, userId: String(user.legacyId), }, }, }, ); if (verbose) { console.log( `[importBetterAuthUsersBatch] create user email=${email} id=${(newUser as { _id?: string } | null)?._id ?? "null"}`, ); } newUserId = (newUser as { _id?: string } | null)?._id ?? undefined; didCreate = Boolean(newUserId); } catch (error: any) { const message = String(error?.message || error); if (verbose) { console.log( `[importBetterAuthUsersBatch] create user error email=${email} message=${message}`, ); } if (message.includes("email already exists")) { skipped += 1; if (fastPath) { continue; } let existing = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "user", where: [ { field: "email", value: email, }, ], }, ); if (!existing?._id) { existing = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "user", where: [ { field: "userId", value: String(user.legacyId), }, ], }, ); } if (existing?._id) { newUserId = existing._id; const existingUserId = (existing as { userId?: string | null }).userId ?? null; if (!existingUserId) { try { await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [ { field: "_id", value: existing._id, }, ], update: { userId: String(user.legacyId) } as any, }, }); } catch (updateError: any) { errors.push({ email, legacyId: user.legacyId, step: "updateExistingUserId", message: String(updateError?.message || updateError), }); } } } if (!newUserId) { errors.push({ email, legacyId: user.legacyId, step: "findExistingUser", message: "User exists but could not be fetched by email/userId.", }); } } else { errors.push({ email, legacyId: user.legacyId, step: "createUser", message, }); } if (!newUserId) { continue; } } if (!newUserId) { continue; } if (user.password) { try { if (!fastPath) { const existingAccount = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "account", where: [ { field: "userId", value: newUserId, }, { field: "providerId", value: "credential", }, ], }, ); if (existingAccount) { continue; } } await ctx.runMutation(components.betterAuth.adapter.create, { input: { model: "account", data: { accountId: newUserId, providerId: "credential", password: user.password, userId: newUserId, createdAt: user.createdAt, updatedAt: user.createdAt, }, }, }); accountsCreated += 1; } catch (error: any) { const message = String(error?.message || error); if (fastPath && message.includes("already exists")) { continue; } accountsFailed += 1; errors.push({ email, legacyId: user.legacyId, step: "createAccount", message, }); } } else { missingPassword += 1; } if (didCreate) { created += 1; } } return { created, skipped, missingPassword, accountsCreated, accountsFailed, errors, }; }, }); export const importBetterAuthAccountsBatch = action({ args: { accounts: v.array(betterAuthAccountInput), verbose: v.optional(v.boolean()), fastPath: v.optional(v.boolean()), }, handler: async (ctx, args) => { const accounts = args.accounts; const verbose = args.verbose ?? false; const fastPath = args.fastPath ?? false; if (verbose) { console.log( `[importBetterAuthAccountsBatch] received ${accounts.length} accounts`, ); } let created = 0; let skipped = 0; let missingUser = 0; const errors: Array<{ providerId: string; legacyUserId: number; legacyAccountId: number; step: string; message: string; }> = []; for (const account of accounts) { if (!account.providerAccountId) { skipped += 1; continue; } let authUser = await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [ { field: "userId", value: String(account.legacyUserId), }, ], }); if (!authUser?._id) { missingUser += 1; continue; } const authUserId = (authUser as { _id: string })._id; if (!fastPath) { const existingAccount = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "account", where: [ { field: "userId", value: authUserId, }, { field: "providerId", value: account.providerId, }, ...(account.providerAccountId ? [ { field: "accountId", value: account.providerAccountId, }, ] : []), ], }, ); if (existingAccount) { skipped += 1; continue; } } try { await ctx.runMutation(components.betterAuth.adapter.create, { input: { model: "account", data: { userId: authUserId, providerId: account.providerId, accountId: account.providerAccountId, accessToken: account.accessToken, refreshToken: account.refreshToken, accessTokenExpiresAt: account.expiresAt, scope: account.scope, idToken: account.idToken, createdAt: account.createdAt, updatedAt: account.updatedAt, } as any, }, }); created += 1; } catch (error: any) { const message = String(error?.message || error); if (fastPath && message.includes("already exists")) { skipped += 1; continue; } errors.push({ providerId: account.providerId, legacyUserId: account.legacyUserId, legacyAccountId: account.legacyAccountId, step: "createAccount", message, }); } } return { created, skipped, missingUser, errors }; }, }); export const clearBetterAuthData = action({ args: { confirm: v.boolean(), }, handler: async (ctx, args) => { if (!args.confirm) { throw new Error("clearBetterAuthData requires confirm=true"); } const models: Array< "session" | "account" | "verification" | "jwks" | "user" > = ["session", "account", "verification", "jwks", "user"]; for (const model of models) { while (true) { const result = (await ctx.runMutation( components.betterAuth.adapter.deleteMany, { input: { model }, paginationOpts: { cursor: null, numItems: 1000, }, }, )) as { count?: number }; if (!result?.count) { break; } } } return { ok: true }; }, }); export const debugBetterAuthAccount = action({ args: { email: v.string(), }, handler: async (ctx, args) => { const email = args.email.toLowerCase(); const user = await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "email", value: email }], }); if (!user?._id) { return { foundUser: false, foundAccount: false }; } const account = await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "account", where: [ { field: "userId", value: user._id }, { field: "providerId", value: "credential" }, ], }); const hash = (account as { password?: string | null } | null)?.password ?? null; return { foundUser: true, foundAccount: Boolean(account), hashPrefix: hash ? hash.slice(0, 4) : null, hashLength: hash ? hash.length : null, }; }, }); ================================================ FILE: convex/betterAuth/_generated/api.ts ================================================ /* eslint-disable */ /** * Generated `api` utility. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type * as adapter from "../adapter.js"; import type * as auth from "../auth.js"; import type { ApiFromModules, FilterApi, FunctionReference, } from "convex/server"; import { anyApi, componentsGeneric } from "convex/server"; const fullApi: ApiFromModules<{ adapter: typeof adapter; auth: typeof auth; }> = anyApi as any; /** * A utility for referencing Convex functions in your app's public API. * * Usage: * ```js * const myFunctionReference = api.myModule.myFunction; * ``` */ export const api: FilterApi< typeof fullApi, FunctionReference > = anyApi as any; /** * A utility for referencing Convex functions in your app's internal API. * * Usage: * ```js * const myFunctionReference = internal.myModule.myFunction; * ``` */ export const internal: FilterApi< typeof fullApi, FunctionReference > = anyApi as any; export const components = componentsGeneric() as unknown as {}; ================================================ FILE: convex/betterAuth/_generated/component.ts ================================================ /* eslint-disable */ /** * Generated `ComponentApi` utility. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type { FunctionReference } from "convex/server"; /** * A utility for referencing a Convex component's exposed API. * * Useful when expecting a parameter like `components.myComponent`. * Usage: * ```ts * async function myFunction(ctx: QueryCtx, component: ComponentApi) { * return ctx.runQuery(component.someFile.someQuery, { ...args }); * } * ``` */ export type ComponentApi = { adapter: { create: FunctionReference< "mutation", "internal", { input: | { data: { banExpires?: null | number; banReason?: null | string; banned?: null | boolean; createdAt: number; displayUsername?: null | string; email: string; emailVerified: boolean; image?: null | string; name: string; role?: null | string; updatedAt: number; userId?: null | string; username?: null | string; }; model: "user"; } | { data: { createdAt: number; expiresAt: number; impersonatedBy?: null | string; ipAddress?: null | string; token: string; updatedAt: number; userAgent?: null | string; userId: string; }; model: "session"; } | { data: { accessToken?: null | string; accessTokenExpiresAt?: null | number; accountId: string; createdAt: number; idToken?: null | string; password?: null | string; providerId: string; refreshToken?: null | string; refreshTokenExpiresAt?: null | number; scope?: null | string; updatedAt: number; userId: string; }; model: "account"; } | { data: { createdAt: number; expiresAt: number; identifier: string; updatedAt: number; value: string; }; model: "verification"; } | { data: { createdAt: number; expiresAt?: null | number; privateKey: string; publicKey: string; }; model: "jwks"; }; onCreateHandle?: string; select?: Array; }, any, Name >; deleteMany: FunctionReference< "mutation", "internal", { input: | { model: "user"; where?: Array<{ connector?: "AND" | "OR"; field: | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "userId" | "username" | "displayUsername" | "role" | "banned" | "banReason" | "banExpires" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "session"; where?: Array<{ connector?: "AND" | "OR"; field: | "expiresAt" | "token" | "createdAt" | "updatedAt" | "ipAddress" | "userAgent" | "userId" | "impersonatedBy" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "account"; where?: Array<{ connector?: "AND" | "OR"; field: | "accountId" | "providerId" | "userId" | "accessToken" | "refreshToken" | "idToken" | "accessTokenExpiresAt" | "refreshTokenExpiresAt" | "scope" | "password" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "verification"; where?: Array<{ connector?: "AND" | "OR"; field: | "identifier" | "value" | "expiresAt" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "jwks"; where?: Array<{ connector?: "AND" | "OR"; field: | "publicKey" | "privateKey" | "createdAt" | "expiresAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }; onDeleteHandle?: string; paginationOpts: { cursor: string | null; endCursor?: string | null; id?: number; maximumBytesRead?: number; maximumRowsRead?: number; numItems: number; }; }, any, Name >; deleteOne: FunctionReference< "mutation", "internal", { input: | { model: "user"; where?: Array<{ connector?: "AND" | "OR"; field: | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "userId" | "username" | "displayUsername" | "role" | "banned" | "banReason" | "banExpires" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "session"; where?: Array<{ connector?: "AND" | "OR"; field: | "expiresAt" | "token" | "createdAt" | "updatedAt" | "ipAddress" | "userAgent" | "userId" | "impersonatedBy" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "account"; where?: Array<{ connector?: "AND" | "OR"; field: | "accountId" | "providerId" | "userId" | "accessToken" | "refreshToken" | "idToken" | "accessTokenExpiresAt" | "refreshTokenExpiresAt" | "scope" | "password" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "verification"; where?: Array<{ connector?: "AND" | "OR"; field: | "identifier" | "value" | "expiresAt" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "jwks"; where?: Array<{ connector?: "AND" | "OR"; field: | "publicKey" | "privateKey" | "createdAt" | "expiresAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }; onDeleteHandle?: string; }, any, Name >; findMany: FunctionReference< "query", "internal", { join?: any; limit?: number; model: "user" | "session" | "account" | "verification" | "jwks"; offset?: number; paginationOpts: { cursor: string | null; endCursor?: string | null; id?: number; maximumBytesRead?: number; maximumRowsRead?: number; numItems: number; }; select?: Array; sortBy?: { direction: "asc" | "desc"; field: string }; where?: Array<{ connector?: "AND" | "OR"; field: string; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }, any, Name >; findOne: FunctionReference< "query", "internal", { join?: any; model: "user" | "session" | "account" | "verification" | "jwks"; select?: Array; where?: Array<{ connector?: "AND" | "OR"; field: string; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }, any, Name >; get: FunctionReference<"query", "internal", { id: string }, any, Name>; searchUsersAll: FunctionReference< "query", "internal", { activatedFilter: "all" | "yes" | "no"; limit: number; roleFilter: "all" | "user" | "contributor" | "admin"; }, any, Name >; searchUsersByEmailPrefix: FunctionReference< "query", "internal", { activatedFilter: "all" | "yes" | "no"; limit: number; prefix: string; roleFilter: "all" | "user" | "contributor" | "admin"; }, any, Name >; searchUsersById: FunctionReference< "query", "internal", { activatedFilter: "all" | "yes" | "no"; id: string; roleFilter: "all" | "user" | "contributor" | "admin"; }, any, Name >; searchUsersByUsernamePrefix: FunctionReference< "query", "internal", { activatedFilter: "all" | "yes" | "no"; limit: number; prefix: string; roleFilter: "all" | "user" | "contributor" | "admin"; }, any, Name >; updateMany: FunctionReference< "mutation", "internal", { input: | { model: "user"; update: { banExpires?: null | number; banReason?: null | string; banned?: null | boolean; createdAt?: number; displayUsername?: null | string; email?: string; emailVerified?: boolean; image?: null | string; name?: string; role?: null | string; updatedAt?: number; userId?: null | string; username?: null | string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "userId" | "username" | "displayUsername" | "role" | "banned" | "banReason" | "banExpires" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "session"; update: { createdAt?: number; expiresAt?: number; impersonatedBy?: null | string; ipAddress?: null | string; token?: string; updatedAt?: number; userAgent?: null | string; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "expiresAt" | "token" | "createdAt" | "updatedAt" | "ipAddress" | "userAgent" | "userId" | "impersonatedBy" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "account"; update: { accessToken?: null | string; accessTokenExpiresAt?: null | number; accountId?: string; createdAt?: number; idToken?: null | string; password?: null | string; providerId?: string; refreshToken?: null | string; refreshTokenExpiresAt?: null | number; scope?: null | string; updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "accountId" | "providerId" | "userId" | "accessToken" | "refreshToken" | "idToken" | "accessTokenExpiresAt" | "refreshTokenExpiresAt" | "scope" | "password" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "verification"; update: { createdAt?: number; expiresAt?: number; identifier?: string; updatedAt?: number; value?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "identifier" | "value" | "expiresAt" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "jwks"; update: { createdAt?: number; expiresAt?: null | number; privateKey?: string; publicKey?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "publicKey" | "privateKey" | "createdAt" | "expiresAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }; onUpdateHandle?: string; paginationOpts: { cursor: string | null; endCursor?: string | null; id?: number; maximumBytesRead?: number; maximumRowsRead?: number; numItems: number; }; }, any, Name >; updateOne: FunctionReference< "mutation", "internal", { input: | { model: "user"; update: { banExpires?: null | number; banReason?: null | string; banned?: null | boolean; createdAt?: number; displayUsername?: null | string; email?: string; emailVerified?: boolean; image?: null | string; name?: string; role?: null | string; updatedAt?: number; userId?: null | string; username?: null | string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "name" | "email" | "emailVerified" | "image" | "createdAt" | "updatedAt" | "userId" | "username" | "displayUsername" | "role" | "banned" | "banReason" | "banExpires" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "session"; update: { createdAt?: number; expiresAt?: number; impersonatedBy?: null | string; ipAddress?: null | string; token?: string; updatedAt?: number; userAgent?: null | string; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "expiresAt" | "token" | "createdAt" | "updatedAt" | "ipAddress" | "userAgent" | "userId" | "impersonatedBy" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "account"; update: { accessToken?: null | string; accessTokenExpiresAt?: null | number; accountId?: string; createdAt?: number; idToken?: null | string; password?: null | string; providerId?: string; refreshToken?: null | string; refreshTokenExpiresAt?: null | number; scope?: null | string; updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "accountId" | "providerId" | "userId" | "accessToken" | "refreshToken" | "idToken" | "accessTokenExpiresAt" | "refreshTokenExpiresAt" | "scope" | "password" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "verification"; update: { createdAt?: number; expiresAt?: number; identifier?: string; updatedAt?: number; value?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "identifier" | "value" | "expiresAt" | "createdAt" | "updatedAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; } | { model: "jwks"; update: { createdAt?: number; expiresAt?: null | number; privateKey?: string; publicKey?: string; }; where?: Array<{ connector?: "AND" | "OR"; field: | "publicKey" | "privateKey" | "createdAt" | "expiresAt" | "_id"; mode?: "sensitive" | "insensitive"; operator?: | "lt" | "lte" | "gt" | "gte" | "eq" | "in" | "not_in" | "ne" | "contains" | "starts_with" | "ends_with"; value: | string | number | boolean | Array | Array | null; }>; }; onUpdateHandle?: string; }, any, Name >; }; }; ================================================ FILE: convex/betterAuth/_generated/dataModel.ts ================================================ /* eslint-disable */ /** * Generated data model types. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type { DataModelFromSchemaDefinition, DocumentByName, TableNamesInDataModel, SystemTableNames, } from "convex/server"; import type { GenericId } from "convex/values"; import schema from "../schema.js"; /** * The names of all of your Convex tables. */ export type TableNames = TableNamesInDataModel; /** * The type of a document stored in Convex. * * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Doc = DocumentByName< DataModel, TableName >; /** * An identifier for a document in Convex. * * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. * * @typeParam TableName - A string literal type of the table name (like "users"). */ export type Id = GenericId; /** * A type describing your Convex data model. * * This type includes information about what tables you have, the type of * documents stored in those tables, and the indexes defined on them. * * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ export type DataModel = DataModelFromSchemaDefinition; ================================================ FILE: convex/betterAuth/_generated/server.ts ================================================ /* eslint-disable */ /** * Generated utilities for implementing server-side Convex query and mutation functions. * * THIS CODE IS AUTOMATICALLY GENERATED. * * To regenerate, run `npx convex dev`. * @module */ import type { ActionBuilder, HttpActionBuilder, MutationBuilder, QueryBuilder, GenericActionCtx, GenericMutationCtx, GenericQueryCtx, GenericDatabaseReader, GenericDatabaseWriter, } from "convex/server"; import { actionGeneric, httpActionGeneric, queryGeneric, mutationGeneric, internalActionGeneric, internalMutationGeneric, internalQueryGeneric, } from "convex/server"; import type { DataModel } from "./dataModel.js"; /** * Define a query in this Convex app's public API. * * This function will be allowed to read your Convex database and will be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export const query: QueryBuilder = queryGeneric; /** * Define a query that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to read from your Convex database. It will not be accessible from the client. * * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ export const internalQuery: QueryBuilder = internalQueryGeneric; /** * Define a mutation in this Convex app's public API. * * This function will be allowed to modify your Convex database and will be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export const mutation: MutationBuilder = mutationGeneric; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). * * This function will be allowed to modify your Convex database. It will not be accessible from the client. * * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ export const internalMutation: MutationBuilder = internalMutationGeneric; /** * Define an action in this Convex app's public API. * * An action is a function which can execute any JavaScript code, including non-deterministic * code and code with side-effects, like calling third-party services. * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. * * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ export const action: ActionBuilder = actionGeneric; /** * Define an action that is only accessible from other Convex functions (but not from the client). * * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ export const internalAction: ActionBuilder = internalActionGeneric; /** * Define an HTTP action. * * The wrapped function will be used to respond to HTTP requests received * by a Convex deployment if the requests matches the path and method where * this action is routed. Be sure to route your httpAction in `convex/http.js`. * * @param func - The function. It receives an {@link ActionCtx} as its first argument * and a Fetch API `Request` object as its second. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. */ export const httpAction: HttpActionBuilder = httpActionGeneric; /** * A set of services for use within Convex query functions. * * The query context is passed as the first argument to any Convex query * function run on the server. * * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead. */ export type QueryCtx = GenericQueryCtx; /** * A set of services for use within Convex mutation functions. * * The mutation context is passed as the first argument to any Convex mutation * function run on the server. * * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead. */ export type MutationCtx = GenericMutationCtx; /** * A set of services for use within Convex action functions. * * The action context is passed as the first argument to any Convex action * function run on the server. */ export type ActionCtx = GenericActionCtx; /** * An interface to read from the database within Convex query functions. * * The two entry points are {@link DatabaseReader.get}, which fetches a single * document by its {@link Id}, or {@link DatabaseReader.query}, which starts * building a query. */ export type DatabaseReader = GenericDatabaseReader; /** * An interface to read from and write to the database within Convex mutation * functions. * * Convex guarantees that all writes within a single mutation are * executed atomically, so you never have to worry about partial writes leaving * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) * for the guarantees Convex provides your functions. */ export type DatabaseWriter = GenericDatabaseWriter; ================================================ FILE: convex/betterAuth/adapter.ts ================================================ import { createApi } from "@convex-dev/better-auth"; import { createAuthOptions } from "./auth"; import schema from "./schema"; import { query } from "./_generated/server"; import { v } from "convex/values"; const activatedFilterValidator = v.union( v.literal("all"), v.literal("yes"), v.literal("no"), ); const roleFilterValidator = v.union( v.literal("all"), v.literal("user"), v.literal("contributor"), v.literal("admin"), ); export const get = query({ args: { id: v.id("user"), }, handler: async (ctx, args) => { return await ctx.db.get("user", args.id); }, }); export const searchUsersById = query({ args: { activatedFilter: activatedFilterValidator, roleFilter: roleFilterValidator, id: v.string(), }, handler: async (ctx, args) => { let query = ctx.db .query("user") .withIndex("userId", (q) => q.eq("userId", args.id)); if (args.roleFilter === "admin") { query = query.filter((q) => q.eq(q.field("role"), "admin")); } else if (args.roleFilter === "contributor") { query = query.filter((q) => q.eq(q.field("role"), "contributor")); } else if (args.roleFilter === "user") { query = query.filter((q) => q.and( q.neq(q.field("role"), "admin"), q.neq(q.field("role"), "contributor"), ), ); } if (args.activatedFilter === "yes") { query = query.filter((q) => q.eq(q.field("emailVerified"), true)); } else if (args.activatedFilter === "no") { query = query.filter((q) => q.eq(q.field("emailVerified"), false)); } return query.take(1); }, }); export const searchUsersByEmailPrefix = query({ args: { activatedFilter: activatedFilterValidator, roleFilter: roleFilterValidator, limit: v.number(), prefix: v.string(), }, handler: async (ctx, args) => { const search = args.prefix.trim(); if (search.length === 0) return []; let query = ctx.db.query("user").withSearchIndex("search_email", (q) => { let built = q.search("email", search); if (args.roleFilter === "admin" || args.roleFilter === "contributor") { built = built.eq("role", args.roleFilter); } if (args.activatedFilter === "yes") { built = built.eq("emailVerified", true); } else if (args.activatedFilter === "no") { built = built.eq("emailVerified", false); } return built; }); if (args.roleFilter === "user") { query = query.filter((q) => q.and( q.neq(q.field("role"), "admin"), q.neq(q.field("role"), "contributor"), ), ); } return query.take(args.limit); }, }); export const searchUsersByUsernamePrefix = query({ args: { activatedFilter: activatedFilterValidator, roleFilter: roleFilterValidator, limit: v.number(), prefix: v.string(), }, handler: async (ctx, args) => { const search = args.prefix.trim(); if (search.length === 0) return []; let query = ctx.db.query("user").withSearchIndex("search_username", (q) => { let built = q.search("username", search); if (args.roleFilter === "admin" || args.roleFilter === "contributor") { built = built.eq("role", args.roleFilter); } if (args.activatedFilter === "yes") { built = built.eq("emailVerified", true); } else if (args.activatedFilter === "no") { built = built.eq("emailVerified", false); } return built; }); if (args.roleFilter === "user") { query = query.filter((q) => q.and( q.neq(q.field("role"), "admin"), q.neq(q.field("role"), "contributor"), ), ); } return query.take(args.limit); }, }); export const searchUsersAll = query({ args: { activatedFilter: activatedFilterValidator, roleFilter: roleFilterValidator, limit: v.number(), }, handler: async (ctx, args) => { if (args.roleFilter === "admin" || args.roleFilter === "contributor") { let query = ctx.db .query("user") .withIndex("role", (q) => q.eq("role", args.roleFilter)) .order("desc"); if (args.activatedFilter === "yes") { query = query.filter((q) => q.eq(q.field("emailVerified"), true)); } else if (args.activatedFilter === "no") { query = query.filter((q) => q.eq(q.field("emailVerified"), false)); } return query.take(args.limit); } let query = ctx.db.query("user").order("desc"); if (args.roleFilter === "user") { query = query.filter((q) => q.and( q.neq(q.field("role"), "admin"), q.neq(q.field("role"), "contributor"), ), ); } if (args.activatedFilter === "yes") { query = query.filter((q) => q.eq(q.field("emailVerified"), true)); } else if (args.activatedFilter === "no") { query = query.filter((q) => q.eq(q.field("emailVerified"), false)); } return query.take(args.limit); }, }); export const { create, findOne, findMany, updateOne, updateMany, deleteOne, deleteMany, } = createApi(schema, createAuthOptions); ================================================ FILE: convex/betterAuth/auth.ts ================================================ import { createClient } from "@convex-dev/better-auth"; import { convex } from "@convex-dev/better-auth/plugins"; import type { GenericCtx } from "@convex-dev/better-auth/utils"; import type { BetterAuthOptions } from "better-auth"; import type { DataModel } from "./_generated/dataModel"; import { betterAuth } from "better-auth"; import { admin, username } from "better-auth/plugins"; import { defaultRoles, userAc } from "better-auth/plugins/admin/access"; import { components, internal } from "../_generated/api"; import authConfig from "../auth.config"; import { syncDiscordAvatarFromAccount } from "../lib/discordAvatarSync"; import { phpbbCheckHash, phpbbHash } from "../lib/phpbb"; import schema from "./schema"; const typedCreateClient = createClient; const getEnv = (...keys: string[]) => keys.map((key) => process.env[key]).find((value) => value); const getSocialProvider = (idKeys: string[], secretKeys: string[]) => { const clientId = getEnv(...idKeys); const clientSecret = getEnv(...secretKeys); if (!clientId || !clientSecret) return undefined; return { clientId, clientSecret }; }; const socialProviders = Object.fromEntries( Object.entries({ github: getSocialProvider( ["GITHUB_CLIENT_ID", "GITHUB_ID", "AUTH_GITHUB_ID"], ["GITHUB_CLIENT_SECRET", "GITHUB_SECRET", "AUTH_GITHUB_SECRET"], ), google: getSocialProvider( ["GOOGLE_CLIENT_ID", "AUTH_GOOGLE_ID"], ["GOOGLE_CLIENT_SECRET", "AUTH_GOOGLE_SECRET"], ), discord: getSocialProvider( ["DISCORD_CLIENT_ID", "AUTH_DISCORD_CLIENT_ID"], ["DISCORD_CLIENT_SECRET", "AUTH_DISCORD_CLIENT_SECRET"], ), facebook: getSocialProvider( ["FACEBOOK_CLIENT_ID", "AUTH_FACEBOOK_ID"], ["FACEBOOK_CLIENT_SECRET", "AUTH_FACEBOOK_SECRET"], ), }).filter(([, value]) => value), ); const sendEmail = async ({ to, subject, html, }: { to: string; subject: string; html: string; }) => { const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { throw new Error("RESEND_API_KEY is not set"); } const response = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "Unofficial Duolingo Stories ", to, subject, html, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Resend email failed: ${response.status} ${text}`); } }; function normalizeExpiresAt(value: number | Date | null | undefined) { if (typeof value === "number") return value; if (value instanceof Date) return value.getTime(); return null; } async function syncDiscordAccountAvatarFromHook( account: { id?: string; userId?: string | null; providerId?: string | null; accountId?: string | null; accessToken?: string | null; refreshToken?: string | null; accessTokenExpiresAt?: number | Date | null; scope?: string | null; } | null, hookContext?: { context: { internalAdapter: { updateUser: ( userId: string, update: Record, ) => Promise; updateAccount: ( accountId: string, update: Record, ) => Promise; }; logger: { error: (message: string, error?: unknown) => void; }; }; } | null, ) { if (!account?.userId || !account.providerId || !hookContext) { return; } try { const result = await syncDiscordAvatarFromAccount(account); if (!result.ok) return; if (result.imageUrl) { await hookContext.context.internalAdapter.updateUser(account.userId, { image: result.imageUrl, }); } if (!account.id) return; const accountUpdate: Record = {}; if (result.accessToken !== account.accessToken) { accountUpdate.accessToken = result.accessToken; } if (result.refreshToken !== account.refreshToken) { accountUpdate.refreshToken = result.refreshToken; } if ( result.accessTokenExpiresAt !== normalizeExpiresAt(account.accessTokenExpiresAt) ) { accountUpdate.accessTokenExpiresAt = result.accessTokenExpiresAt; } if (result.scope !== account.scope) { accountUpdate.scope = result.scope; } if (Object.keys(accountUpdate).length > 0) { await hookContext.context.internalAdapter.updateAccount( account.id, accountUpdate, ); } } catch (error) { hookContext.context.logger.error("Failed to sync Discord avatar", error); } } // Better Auth Component export const authComponent: ReturnType = typedCreateClient(components.betterAuth, { local: { schema }, verbose: false, authFunctions: { onCreate: internal.authFunctions.onCreate, }, triggers: { user: { onCreate: async () => {}, }, }, }); const authBaseUrl = process.env.SITE_URL ?? process.env.BETTER_AUTH_URL ?? process.env.NEXTAUTH_URL; // Better Auth Options export const createAuthOptions = (ctx: GenericCtx) => { return { appName: "Duostories", baseURL: authBaseUrl, trustedOrigins: async (req) => { const host = req?.headers.get("x-forwarded-host") ?? req?.headers.get("host"); const proto = req?.headers.get("x-forwarded-proto") ?? "https"; const origin = host ? `${proto}://${host}` : null; const allowed = [ "http://localhost:3000", authBaseUrl, "https://*-duostories-team.vercel.app", ].filter(Boolean) as string[]; if (host?.endsWith("-duostories-team.vercel.app") && origin) { allowed.push(origin); } return allowed; }, secret: process.env.BETTER_AUTH_SECRET, socialProviders, database: authComponent.adapter(ctx), databaseHooks: { account: { create: { after: async (account, hookContext) => { await syncDiscordAccountAvatarFromHook(account, hookContext); }, }, update: { after: async (account, hookContext) => { await syncDiscordAccountAvatarFromHook(account, hookContext); }, }, }, }, emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "[Unofficial Duolingo Stories] Reset Password", html: `Hey ${user.name ?? "there"},

You have requested to reset your password for 'Unofficial Duolingo Stories'.
Use the following link to reset your password.
Reset Password

Happy learning.`, }); }, password: { hash: async (password) => phpbbHash(password), verify: async ({ password, hash }) => phpbbCheckHash(password, hash), }, }, emailVerification: { sendOnSignUp: true, sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "[Unofficial Duolingo Stories] Verify Email", html: `Hey ${user.name ?? "there"},

Please verify your email address by clicking the link below.
Verify Email

Happy learning.`, }); }, }, user: { changeEmail: { enabled: true, updateEmailWithoutVerification: false, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { await sendEmail({ to: user.email, subject: "[Unofficial Duolingo Stories] Confirm Email Change", html: `Hey ${user.name ?? "there"},

You requested to change your account email to ${newEmail}.
Confirm this change using the link below.
Confirm Email Change

If this was not you, you can ignore this email.`, }); }, }, }, plugins: [ convex({ authConfig }), username(), admin({ adminRoles: ["admin"], roles: { ...defaultRoles, contributor: userAc, }, }), ], } satisfies BetterAuthOptions; }; // For `@better-auth/cli` const options = createAuthOptions({} as GenericCtx); // Better Auth Instance export const createAuth = (ctx: GenericCtx) => { return betterAuth(createAuthOptions(ctx)); }; ================================================ FILE: convex/betterAuth/convex.config.ts ================================================ import { defineComponent } from "convex/server"; const component = defineComponent("betterAuth"); export default component; ================================================ FILE: convex/betterAuth/schema.ts ================================================ /** * This file is auto-generated. Do not edit this file manually. * To regenerate the schema, run: * `npx @better-auth/cli generate --output ./convex/betterAuth/schema.ts -y` * * To customize the schema, generate to an alternate file and import * the table definitions to your schema file. See * https://labs.convex.dev/better-auth/features/local-install#adding-custom-indexes. */ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; const tables = { user: defineTable({ name: v.string(), email: v.string(), emailVerified: v.boolean(), image: v.optional(v.union(v.null(), v.string())), createdAt: v.number(), updatedAt: v.number(), userId: v.optional(v.union(v.null(), v.string())), username: v.optional(v.union(v.null(), v.string())), displayUsername: v.optional(v.union(v.null(), v.string())), role: v.optional(v.union(v.null(), v.string())), banned: v.optional(v.union(v.null(), v.boolean())), banReason: v.optional(v.union(v.null(), v.string())), banExpires: v.optional(v.union(v.null(), v.number())), }) .index("email", ["email"]) .index("email_name", ["email", "name"]) .index("name", ["name"]) .index("role", ["role"]) .index("role_createdAt", ["role", "createdAt"]) .index("userId", ["userId"]) .index("username", ["username"]) .searchIndex("search_email", { searchField: "email", filterFields: ["role", "emailVerified"], }) .searchIndex("search_username", { searchField: "username", filterFields: ["role", "emailVerified"], }), session: defineTable({ expiresAt: v.number(), token: v.string(), createdAt: v.number(), updatedAt: v.number(), ipAddress: v.optional(v.union(v.null(), v.string())), userAgent: v.optional(v.union(v.null(), v.string())), userId: v.string(), impersonatedBy: v.optional(v.union(v.null(), v.string())), }) .index("expiresAt", ["expiresAt"]) .index("expiresAt_userId", ["expiresAt", "userId"]) .index("token", ["token"]) .index("userId", ["userId"]), account: defineTable({ accountId: v.string(), providerId: v.string(), userId: v.string(), accessToken: v.optional(v.union(v.null(), v.string())), refreshToken: v.optional(v.union(v.null(), v.string())), idToken: v.optional(v.union(v.null(), v.string())), accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())), refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())), scope: v.optional(v.union(v.null(), v.string())), password: v.optional(v.union(v.null(), v.string())), createdAt: v.number(), updatedAt: v.number(), }) .index("accountId", ["accountId"]) .index("accountId_providerId", ["accountId", "providerId"]) .index("providerId_userId", ["providerId", "userId"]) .index("userId", ["userId"]), verification: defineTable({ identifier: v.string(), value: v.string(), expiresAt: v.number(), createdAt: v.number(), updatedAt: v.number(), }) .index("expiresAt", ["expiresAt"]) .index("identifier", ["identifier"]), jwks: defineTable({ publicKey: v.string(), privateKey: v.string(), createdAt: v.number(), expiresAt: v.optional(v.union(v.null(), v.number())), }), }; const schema = defineSchema(tables); export default schema; ================================================ FILE: convex/convex-env.d.ts ================================================ declare namespace NodeJS { interface ProcessEnv { [key: string]: string | undefined; } interface Process { env: ProcessEnv; exit(code?: number): never; } } declare var process: NodeJS.Process; ================================================ FILE: convex/convex.config.ts ================================================ import { defineApp } from "convex/server"; import betterAuth from "./betterAuth/convex.config"; const app = defineApp(); app.use(betterAuth); export default app; ================================================ FILE: convex/convex_rules.md ================================================ # Convex guidelines ## Function guidelines ### New function syntax - ALWAYS use the new function syntax for Convex functions. For example: ```typescript import { query } from "./_generated/server"; import { v } from "convex/values"; export const f = query({ args: {}, returns: v.null(), handler: async (ctx, args) => { // Function body }, }); ``` ### Http endpoint syntax - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: ```typescript import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; const http = httpRouter(); http.route({ path: "/echo", method: "POST", handler: httpAction(async (ctx, req) => { const body = await req.bytes(); return new Response(body, { status: 200 }); }), }); ``` - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. ### Validators - Below is an example of an array validator: ```typescript import { mutation } from "./_generated/server"; import { v } from "convex/values"; export default mutation({ args: { simpleArray: v.array(v.union(v.string(), v.number())), }, handler: async (ctx, args) => { //... }, }); ``` - Below is an example of a schema with validators that codify a discriminated union type: ```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ results: defineTable( v.union( v.object({ kind: v.literal("error"), errorMessage: v.string(), }), v.object({ kind: v.literal("success"), value: v.number(), }), ), ), }); ``` - Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: ```typescript import { query } from "./_generated/server"; import { v } from "convex/values"; export const exampleQuery = query({ args: {}, returns: v.null(), handler: async (ctx, args) => { console.log("This query returns a null value"); return null; }, }); ``` - Here are the valid Convex types along with their respective validators: Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Id | string | `doc._id` | `v.id(tableName)` | | | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | | Boolean | boolean | `true` | `v.boolean()` | | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | | Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | ### Function registration - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. - You CANNOT register a function through the `api` or `internal` objects. - ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator. - If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. ### Function calling - Use `ctx.runQuery` to call a query from a query, mutation, or action. - Use `ctx.runMutation` to call a mutation from a mutation or action. - Use `ctx.runAction` to call an action from an action. - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, ``` export const f = query({ args: { name: v.string() }, returns: v.string(), handler: async (ctx, args) => { return "Hello " + args.name; }, }); export const g = query({ args: {}, returns: v.null(), handler: async (ctx, args) => { const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); return null; }, }); ``` ### Function references - Function references are pointers to registered Convex functions. - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. - A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. ### Api design - Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory. - Use `query`, `mutation`, and `action` to define public functions. - Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions. ### Pagination - Paginated queries are queries that return a list of results in incremental pages. - You can define pagination using the following syntax: ```ts import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; export const listWithExtraArg = query({ args: { paginationOpts: paginationOptsValidator, author: v.string() }, handler: async (ctx, args) => { return await ctx.db .query("messages") .withIndex("by_author", (q) => q.eq("author", args.author)) .order("desc") .paginate(args.paginationOpts); }, }); ``` Note: `paginationOpts` is an object with the following properties: - `numItems`: the maximum number of documents to return (the validator is `v.number()`) - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents) ## Validator guidelines - `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead. - Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported. ## Schema guidelines - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. ## Typescript guidelines - You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: ```ts import { query } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; export const exampleQuery = query({ args: { userIds: v.array(v.id("users")) }, returns: v.record(v.id("users"), v.string()), handler: async (ctx, args) => { const idToUsername: Record, string> = {}; for (const userId of args.userIds) { const user = await ctx.db.get("users", userId); if (user) { idToUsername[user._id] = user.username; } } return idToUsername; }, }); ``` - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. - Always use `as const` for string literals in discriminated union types. - When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` - When using the `Record` type, make sure to always define your records as `const record: Record = {...};` - Always add `@types/node` to your `package.json` when using any Node.js built-in modules. ## Full text search guidelines - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: const messages = await ctx.db .query("messages") .withSearchIndex("search_body", (q) => q.search("body", "hello hi").eq("channel", "#general"), ) .take(10); ## Query guidelines - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. - Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result. - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. ### Ordering - By default Convex always returns documents in ascending `_creationTime` order. - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. ## Mutation guidelines - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: ```ts import { action } from "./_generated/server"; export const exampleAction = action({ args: {}, returns: v.null(), handler: async (ctx, args) => { console.log("This action does not return anything"); return null; }, }); ``` ## Scheduling guidelines ### Cron guidelines - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, ```ts import { cronJobs } from "convex/server"; import { internal } from "./_generated/api"; import { internalAction } from "./_generated/server"; const empty = internalAction({ args: {}, returns: v.null(), handler: async (ctx, args) => { console.log("empty"); }, }); const crons = cronJobs(); // Run `internal.crons.empty` every two hours. crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); export default crons; ``` - You can register Convex functions within `crons.ts` just like any other file. - If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. ## File storage guidelines - Convex includes file storage for large files like images, videos, and PDFs. - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. ``` import { query } from "./_generated/server"; import { Id } from "./_generated/dataModel"; type FileMetadata = { _id: Id<"_storage">; _creationTime: number; contentType?: string; sha256: string; size: number; } export const exampleQuery = query({ args: { fileId: v.id("_storage") }, returns: v.null(), handler: async (ctx, args) => { const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); console.log(metadata); return null; }, }); ``` - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. # Examples: ## Example: chat-app ### Task ``` Create a real-time chat application backend with AI responses. The app should: - Allow creating users with names - Support multiple chat channels - Enable users to send messages to channels - Automatically generate AI responses to user messages - Show recent message history The backend should provide APIs for: 1. User management (creation) 2. Channel management (creation) 3. Message operations (sending, listing) 4. AI response generation using OpenAI's GPT-4 Messages should be stored with their channel, author, and content. The system should maintain message order and limit history display to the 10 most recent messages per channel. ``` ### Analysis 1. Task Requirements Summary: - Build a real-time chat backend with AI integration - Support user creation - Enable channel-based conversations - Store and retrieve messages with proper ordering - Generate AI responses automatically 2. Main Components Needed: - Database tables: users, channels, messages - Public APIs for user/channel management - Message handling functions - Internal AI response generation system - Context loading for AI responses 3. Public API and Internal Functions Design: Public Mutations: - createUser: - file path: convex/index.ts - arguments: {name: v.string()} - returns: v.object({userId: v.id("users")}) - purpose: Create a new user with a given name - createChannel: - file path: convex/index.ts - arguments: {name: v.string()} - returns: v.object({channelId: v.id("channels")}) - purpose: Create a new channel with a given name - sendMessage: - file path: convex/index.ts - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()} - returns: v.null() - purpose: Send a message to a channel and schedule a response from the AI Public Queries: - listMessages: - file path: convex/index.ts - arguments: {channelId: v.id("channels")} - returns: v.array(v.object({ \_id: v.id("messages"), \_creationTime: v.number(), channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string(), })) - purpose: List the 10 most recent messages from a channel in descending creation order Internal Functions: - generateResponse: - file path: convex/index.ts - arguments: {channelId: v.id("channels")} - returns: v.null() - purpose: Generate a response from the AI for a given channel - loadContext: - file path: convex/index.ts - arguments: {channelId: v.id("channels")} - returns: v.array(v.object({ \_id: v.id("messages"), \_creationTime: v.number(), channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string(), })) - writeAgentResponse: - file path: convex/index.ts - arguments: {channelId: v.id("channels"), content: v.string()} - returns: v.null() - purpose: Write an AI response to a given channel 4. Schema Design: - users - validator: { name: v.string() } - indexes: - channels - validator: { name: v.string() } - indexes: - messages - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() } - indexes - by_channel: ["channelId"] 5. Background Processing: - AI response generation runs asynchronously after each user message - Uses OpenAI's GPT-4 to generate contextual responses - Maintains conversation context using recent message history ### Implementation #### package.json ```typescript { "name": "chat-app", "description": "This example shows how to build a chat app without authentication.", "version": "1.0.0", "dependencies": { "convex": "^1.31.2", "openai": "^4.79.0" }, "devDependencies": { "typescript": "^5.7.3" } } ``` #### tsconfig.json ```typescript { "compilerOptions": { "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "skipLibCheck": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, "noEmit": true, "jsx": "react-jsx" }, "exclude": ["convex"], "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"] } ``` #### convex/index.ts ```typescript import { query, mutation, internalQuery, internalMutation, internalAction, } from "./_generated/server"; import { v } from "convex/values"; import OpenAI from "openai"; import { internal } from "./_generated/api"; /** * Create a user with a given name. */ export const createUser = mutation({ args: { name: v.string(), }, returns: v.id("users"), handler: async (ctx, args) => { return await ctx.db.insert("users", { name: args.name }); }, }); /** * Create a channel with a given name. */ export const createChannel = mutation({ args: { name: v.string(), }, returns: v.id("channels"), handler: async (ctx, args) => { return await ctx.db.insert("channels", { name: args.name }); }, }); /** * List the 10 most recent messages from a channel in descending creation order. */ export const listMessages = query({ args: { channelId: v.id("channels"), }, returns: v.array( v.object({ _id: v.id("messages"), _creationTime: v.number(), channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string(), }), ), handler: async (ctx, args) => { const messages = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("desc") .take(10); return messages; }, }); /** * Send a message to a channel and schedule a response from the AI. */ export const sendMessage = mutation({ args: { channelId: v.id("channels"), authorId: v.id("users"), content: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const channel = await ctx.db.get(args.channelId); if (!channel) { throw new Error("Channel not found"); } const user = await ctx.db.get(args.authorId); if (!user) { throw new Error("User not found"); } await ctx.db.insert("messages", { channelId: args.channelId, authorId: args.authorId, content: args.content, }); await ctx.scheduler.runAfter(0, internal.index.generateResponse, { channelId: args.channelId, }); return null; }, }); const openai = new OpenAI(); export const generateResponse = internalAction({ args: { channelId: v.id("channels"), }, returns: v.null(), handler: async (ctx, args) => { const context = await ctx.runQuery(internal.index.loadContext, { channelId: args.channelId, }); const response = await openai.chat.completions.create({ model: "gpt-4o", messages: context, }); const content = response.choices[0].message.content; if (!content) { throw new Error("No content in response"); } await ctx.runMutation(internal.index.writeAgentResponse, { channelId: args.channelId, content, }); return null; }, }); export const loadContext = internalQuery({ args: { channelId: v.id("channels"), }, returns: v.array( v.object({ role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), }), ), handler: async (ctx, args) => { const channel = await ctx.db.get(args.channelId); if (!channel) { throw new Error("Channel not found"); } const messages = await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("desc") .take(10); const result = []; for (const message of messages) { if (message.authorId) { const user = await ctx.db.get(message.authorId); if (!user) { throw new Error("User not found"); } result.push({ role: "user" as const, content: `${user.name}: ${message.content}`, }); } else { result.push({ role: "assistant" as const, content: message.content }); } } return result; }, }); export const writeAgentResponse = internalMutation({ args: { channelId: v.id("channels"), content: v.string(), }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.insert("messages", { channelId: args.channelId, content: args.content, }); return null; }, }); ``` #### convex/schema.ts ```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ channels: defineTable({ name: v.string(), }), users: defineTable({ name: v.string(), }), messages: defineTable({ channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string(), }).index("by_channel", ["channelId"]), }); ``` #### convex/tsconfig.json ```typescript { /* This TypeScript project config describes the environment that * Convex functions run in and is used to typecheck them. * You can modify it, but some settings required to use Convex. */ "compilerOptions": { /* These settings are not required by Convex and can be modified. */ "allowJs": true, "strict": true, "moduleResolution": "Bundler", "jsx": "react-jsx", "skipLibCheck": true, "allowSyntheticDefaultImports": true, /* These compiler options are required by Convex */ "target": "ESNext", "lib": ["ES2021", "dom"], "forceConsistentCasingInFileNames": true, "module": "ESNext", "isolatedModules": true, "noEmit": true }, "include": ["./**/*"], "exclude": ["./_generated"] } ``` #### src/App.tsx ```typescript export default function App() { return
Hello World
; } ``` ================================================ FILE: convex/courseContributorBackfill.ts ================================================ import { v } from "convex/values"; import { internal } from "./_generated/api"; import { httpAction, internalMutation } from "./_generated/server"; import { getRankedCourseContributors, partitionCourseContributors, } from "./lib/courseContributors"; const DEFAULT_BATCH_SIZE = 10; const MAX_BATCH_SIZE = 25; function normalizeBatchSize(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_BATCH_SIZE; } return Math.max(1, Math.min(MAX_BATCH_SIZE, Math.floor(value))); } function json(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } async function requireCourseContributorBackfillSecret(req: Request) { const expectedSecret = process.env.COURSE_CONTRIBUTOR_BACKFILL_SECRET; if (!expectedSecret) { return { ok: false, response: json( { ok: false, error: "Missing COURSE_CONTRIBUTOR_BACKFILL_SECRET env var", }, 500, ), } as const; } let body: unknown; try { body = await req.json(); } catch { return { ok: false, response: json({ ok: false, error: "Invalid JSON body" }, 400), } as const; } const parsed = body as { secret?: unknown }; if (parsed.secret !== expectedSecret) { return { ok: false, response: json({ ok: false, error: "Unauthorized" }, 401), } as const; } return { ok: true, body } as const; } const backfillResultValidator = v.object({ processed: v.number(), updatedCourses: v.number(), nextCursor: v.union(v.string(), v.null()), isDone: v.boolean(), errors: v.array( v.object({ courseId: v.number(), message: v.string(), }), ), }); export const backfillCourseContributorDetailsBatchInternal = internalMutation({ args: { batchSize: v.optional(v.number()), cursor: v.optional(v.union(v.string(), v.null())), dryRun: v.optional(v.boolean()), }, returns: backfillResultValidator, handler: async (ctx, args) => { const batchSize = normalizeBatchSize(args.batchSize); const page = await ctx.db.query("courses").paginate({ cursor: args.cursor ?? null, numItems: batchSize, }); let updatedCourses = 0; const errors: Array<{ courseId: number; message: string }> = []; for (const course of page.page) { try { const contributorLists = partitionCourseContributors( await getRankedCourseContributors(ctx, course._id), ); if (!args.dryRun) { await ctx.db.patch(course._id, { contributors: contributorLists.contributors.map((row) => row.name), contributors_past: contributorLists.contributors_past.map( (row) => row.name, ), contributorDetails: contributorLists.contributors, contributorDetailsPast: contributorLists.contributors_past, }); } updatedCourses += 1; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); errors.push({ courseId: course.legacyId, message, }); } } return { processed: page.page.length, updatedCourses, nextCursor: page.continueCursor, isDone: page.isDone, errors, }; }, }); export const backfillCourseContributorDetailsHttp = httpAction( async (ctx, req) => { if (req.method !== "POST") { return json({ ok: false, error: "Method not allowed" }, 405); } const auth = await requireCourseContributorBackfillSecret(req); if (!auth.ok) return auth.response; const body = auth.body as { batchSize?: unknown; cursor?: unknown; dryRun?: unknown; }; if (body.batchSize !== undefined && typeof body.batchSize !== "number") { return json({ ok: false, error: "batchSize must be a number" }, 400); } if ( body.cursor !== undefined && body.cursor !== null && typeof body.cursor !== "string" ) { return json({ ok: false, error: "cursor must be a string or null" }, 400); } if (body.dryRun !== undefined && typeof body.dryRun !== "boolean") { return json({ ok: false, error: "dryRun must be a boolean" }, 400); } const result = await ctx.runMutation( internal.courseContributorBackfill .backfillCourseContributorDetailsBatchInternal, { batchSize: typeof body.batchSize === "number" ? body.batchSize : undefined, cursor: typeof body.cursor === "string" || body.cursor === null ? body.cursor : undefined, dryRun: typeof body.dryRun === "boolean" ? body.dryRun : undefined, }, ); return json(result); }, ); ================================================ FILE: convex/courseWrite.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireContributorOrAdmin } from "./lib/authorization"; import { recomputeCoursePublishedCount } from "./lib/courseCounts"; export const recomputePublishedCount = mutation({ args: { legacyCourseId: v.number(), }, returns: v.object({ count: v.number(), updated: v.boolean(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const course = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyCourseId)) .unique(); if (!course) { throw new Error(`Course ${args.legacyCourseId} not found`); } const previousCount = course.count ?? 0; const count = await recomputeCoursePublishedCount(ctx, course._id); return { count, updated: previousCount !== count, }; }, }); ================================================ FILE: convex/discordAvatarSync.ts ================================================ import { action, httpAction, internalAction, type ActionCtx, } from "./_generated/server"; import { v } from "convex/values"; import { components, internal } from "./_generated/api"; import { syncDiscordAvatarFromAccount } from "./lib/discordAvatarSync"; type AdapterWhere = Array<{ field: string; operator?: "eq" | "in"; value: string | Array; }>; type AuthAccountRow = { _id?: string | null; userId?: string | null; providerId?: string | null; accountId?: string | null; accessToken?: string | null; refreshToken?: string | null; accessTokenExpiresAt?: number | null; scope?: string | null; }; type PaginatedAdapterResponse = { page: T[]; isDone?: boolean; continueCursor?: string | null; }; type BackfillArgs = { batchSize?: number; cursor?: string | null; dryRun?: boolean; }; type BackfillResult = { processed: number; updatedUsers: number; updatedAccounts: number; skipped: number; nextCursor: string | null; isDone: boolean; errors: Array<{ accountId: string | null; userId: string | null; message: string; }>; }; const DEFAULT_BATCH_SIZE = 25; const MAX_BATCH_SIZE = 100; function json(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } async function requireDiscordAvatarSyncSecret(req: Request) { const expectedSecret = process.env.DISCORD_AVATAR_SYNC_SECRET; if (!expectedSecret) { return { ok: false, response: json( { ok: false, error: "Missing DISCORD_AVATAR_SYNC_SECRET env var" }, 500, ), } as const; } let body: unknown; try { body = await req.json(); } catch { return { ok: false, response: json({ ok: false, error: "Invalid JSON body" }, 400), } as const; } const parsed = body as { secret?: unknown }; if (parsed.secret !== expectedSecret) { return { ok: false, response: json({ ok: false, error: "Unauthorized" }, 401), } as const; } return { ok: true, body } as const; } function normalizeBatchSize(value: number | undefined) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_BATCH_SIZE; } return Math.max(1, Math.min(MAX_BATCH_SIZE, Math.floor(value))); } function dedupeAccounts(accounts: AuthAccountRow[]) { const seen = new Set(); const unique: AuthAccountRow[] = []; for (const account of accounts) { const key = `${account.userId ?? ""}:${account.accountId ?? ""}`; if (seen.has(key)) continue; seen.add(key); unique.push(account); } return unique; } async function findManyPage( ctx: ActionCtx, model: "account", where: AdapterWhere, cursor: string | null, batchSize: number, ): Promise> { return (await ctx.runQuery(components.betterAuth.adapter.findMany, { model, where, paginationOpts: { cursor, numItems: batchSize }, })) as PaginatedAdapterResponse; } async function runDiscordAvatarBackfill( ctx: ActionCtx, args: BackfillArgs, ): Promise { const batchSize = normalizeBatchSize(args.batchSize); const page = await findManyPage( ctx, "account", [{ field: "providerId", operator: "eq", value: "discord" }], args.cursor ?? null, batchSize, ); let processed = 0; let updatedUsers = 0; let updatedAccounts = 0; let skipped = 0; const errors: Array<{ accountId: string | null; userId: string | null; message: string; }> = []; for (const account of dedupeAccounts(page.page)) { processed += 1; if ( typeof account._id !== "string" || typeof account.userId !== "string" || typeof account.providerId !== "string" ) { skipped += 1; continue; } try { const result = await syncDiscordAvatarFromAccount(account); if (!result.ok) { skipped += 1; continue; } if (!args.dryRun) { if (result.imageUrl) { await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: account.userId }], update: { image: result.imageUrl }, }, }); updatedUsers += 1; } const accountUpdate: Record = {}; if (result.accessToken !== account.accessToken) { accountUpdate.accessToken = result.accessToken; } if (result.refreshToken !== account.refreshToken) { accountUpdate.refreshToken = result.refreshToken; } if (result.accessTokenExpiresAt !== account.accessTokenExpiresAt) { accountUpdate.accessTokenExpiresAt = result.accessTokenExpiresAt; } if (result.scope !== account.scope) { accountUpdate.scope = result.scope; } if (Object.keys(accountUpdate).length > 0) { await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "account", where: [{ field: "_id", value: account._id }], update: accountUpdate, }, }); updatedAccounts += 1; } } else { if (result.imageUrl) updatedUsers += 1; if ( result.accessToken !== account.accessToken || result.refreshToken !== account.refreshToken || result.accessTokenExpiresAt !== account.accessTokenExpiresAt || result.scope !== account.scope ) { updatedAccounts += 1; } } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); errors.push({ accountId: account.accountId ?? null, userId: account.userId ?? null, message, }); } } return { processed, updatedUsers, updatedAccounts, skipped, nextCursor: page.continueCursor ?? null, isDone: page.isDone ?? true, errors, }; } const backfillArgsValidator = { batchSize: v.optional(v.number()), cursor: v.optional(v.union(v.string(), v.null())), dryRun: v.optional(v.boolean()), }; const backfillReturnsValidator = v.object({ processed: v.number(), updatedUsers: v.number(), updatedAccounts: v.number(), skipped: v.number(), nextCursor: v.union(v.string(), v.null()), isDone: v.boolean(), errors: v.array( v.object({ accountId: v.union(v.string(), v.null()), userId: v.union(v.string(), v.null()), message: v.string(), }), ), }); export const backfillDiscordUserImagesInternal = internalAction({ args: backfillArgsValidator, returns: backfillReturnsValidator, handler: async (ctx, args) => { return await runDiscordAvatarBackfill(ctx, args); }, }); export const backfillDiscordUserImages = action({ args: backfillArgsValidator, returns: backfillReturnsValidator, handler: async (ctx, args) => { const identity = (await ctx.auth.getUserIdentity()) as { role?: string | null; } | null; if (identity?.role !== "admin") { throw new Error("Unauthorized"); } return await runDiscordAvatarBackfill(ctx, args); }, }); export const backfillDiscordUserImagesHttp = httpAction(async (ctx, req) => { if (req.method !== "POST") { return json({ ok: false, error: "Method not allowed" }, 405); } const auth = await requireDiscordAvatarSyncSecret(req); if (!auth.ok) return auth.response; const body = auth.body as { batchSize?: unknown; cursor?: unknown; dryRun?: unknown; }; if (body.batchSize !== undefined && typeof body.batchSize !== "number") { return json({ ok: false, error: "batchSize must be a number" }, 400); } if ( body.cursor !== undefined && body.cursor !== null && typeof body.cursor !== "string" ) { return json({ ok: false, error: "cursor must be a string or null" }, 400); } if (body.dryRun !== undefined && typeof body.dryRun !== "boolean") { return json({ ok: false, error: "dryRun must be a boolean" }, 400); } const result: BackfillResult = await ctx.runAction( internal.discordAvatarSync.backfillDiscordUserImagesInternal, { batchSize: typeof body.batchSize === "number" ? body.batchSize : undefined, cursor: typeof body.cursor === "string" || body.cursor === null ? body.cursor : undefined, dryRun: typeof body.dryRun === "boolean" ? body.dryRun : undefined, }, ); return json(result); }); ================================================ FILE: convex/discordBot.ts ================================================ import { components, internal } from "./_generated/api"; import { httpAction } from "./_generated/server"; type Role = "user" | "contributor" | "admin"; function json(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } async function requireDiscordSyncSecret(req: Request) { const expectedSecret = process.env.DISCORD_ROLE_SYNC_SECRET; if (!expectedSecret) { return { ok: false, response: json( { ok: false, error: "Missing DISCORD_ROLE_SYNC_SECRET env var" }, 500, ), } as const; } let body: unknown; try { body = await req.json(); } catch { return { ok: false, response: json({ ok: false, error: "Invalid JSON body" }, 400), } as const; } const parsed = body as { secret?: unknown }; if (parsed.secret !== expectedSecret) { return { ok: false, response: json({ ok: false, error: "Unauthorized" }, 401), } as const; } return { ok: true, body } as const; } type CombineKind = "users" | "publicStories" | "approvals"; type StoriesRoleSyncStatus = | "assigned" | "up_to_date" | "no_milestone" | "not_linked" | "member_not_found" | "error"; function parseStoriesRoleSyncStatus( value: unknown, ): StoriesRoleSyncStatus | null { if ( value === "assigned" || value === "up_to_date" || value === "no_milestone" || value === "not_linked" || value === "member_not_found" || value === "error" ) { return value; } return null; } function parseNumItems(value: unknown) { if (typeof value !== "number" || !Number.isInteger(value)) return 200; return Math.max(1, Math.min(500, value)); } function parseKind(value: unknown): CombineKind | null { if (value === "users" || value === "publicStories" || value === "approvals") { return value; } return null; } export const setContributorWriteByDiscordAccountId = httpAction( async (ctx, req) => { if (req.method !== "POST") { return json({ ok: false, error: "Method not allowed" }, 405); } const auth = await requireDiscordSyncSecret(req); if (!auth.ok) return auth.response; const parsed = auth.body as { discordAccountId?: unknown; write?: unknown; }; if (typeof parsed.discordAccountId !== "string") { return json( { ok: false, error: "discordAccountId must be a string" }, 400, ); } if (typeof parsed.write !== "boolean" && parsed.write !== null) { return json({ ok: false, error: "write must be a boolean or null" }, 400); } const account = (await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "account", where: [ { field: "providerId", value: "discord" }, { field: "accountId", value: parsed.discordAccountId }, ], })) as { userId?: string | null } | null; if (!account?.userId) { return json({ ok: true, linked: false }); } const user = (await ctx.runQuery(components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "_id", value: account.userId }], })) as { _id?: string; userId?: string | null; name?: string | null; role?: string | null; } | null; if (!user?._id) { return json({ ok: true, linked: false }); } const currentRole: Role = user.role === "admin" || user.role === "contributor" ? user.role : "user"; let nextRole: Role = currentRole; if (typeof parsed.write === "boolean") { if (parsed.write && currentRole === "user") nextRole = "contributor"; if (!parsed.write && currentRole === "contributor") nextRole = "user"; if (nextRole !== currentRole) { await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: user._id }], update: { role: nextRole }, }, }); } } const legacyId = Number.parseInt(user.userId ?? "", 10); return json({ ok: true, linked: true, updated: nextRole !== currentRole, user: { id: Number.isFinite(legacyId) ? legacyId : null, name: user.name ?? "", role: nextRole, }, }); }, ); export const getDiscordCombineData = httpAction(async (ctx, req) => { if (req.method !== "POST") { return json({ ok: false, error: "Method not allowed" }, 405); } const auth = await requireDiscordSyncSecret(req); if (!auth.ok) return auth.response; const body = auth.body as { kind?: unknown; cursor?: unknown; numItems?: unknown; sinceDate?: unknown; }; const kind = parseKind(body.kind); if (!kind) { return json( { ok: false, error: "kind must be one of users, publicStories, or approvals", }, 400, ); } const paginationOpts = { cursor: typeof body.cursor === "string" ? body.cursor : null, numItems: parseNumItems(body.numItems), }; if (kind === "users") { const users = await ctx.runQuery( internal.discordData.getContributorDiscordLinks, {}, ); return json({ ok: true, users }); } if (kind === "publicStories") { const page = await ctx.runQuery( internal.discordData.getPublicStoryIdsPage, { paginationOpts, }, ); return json({ ok: true, ...page }); } if ( body.sinceDate !== undefined && (typeof body.sinceDate !== "number" || !Number.isInteger(body.sinceDate)) ) { return json({ ok: false, error: "sinceDate must be an integer" }, 400); } const page = await ctx.runQuery(internal.discordData.getApprovalPage, { paginationOpts, sinceDate: typeof body.sinceDate === "number" ? body.sinceDate : undefined, }); return json({ ok: true, ...page }); }); export const setStoriesRoleSyncStatus = httpAction(async (ctx, req) => { if (req.method !== "POST") { return json({ ok: false, error: "Method not allowed" }, 405); } const auth = await requireDiscordSyncSecret(req); if (!auth.ok) return auth.response; const body = auth.body as { snapshots?: unknown; }; if (!Array.isArray(body.snapshots)) { return json({ ok: false, error: "snapshots must be an array" }, 400); } const snapshots: Array<{ legacyUserId: number; discordAccountId: string | null; eligibleStoriesCount: number | null; assignedStoriesCount: number | null; syncStatus: StoriesRoleSyncStatus; lastSyncedAt: number; lastError: string | null; }> = []; for (const snapshot of body.snapshots) { if (!snapshot || typeof snapshot !== "object") { return json( { ok: false, error: "snapshot entries must be objects" }, 400, ); } const parsed = snapshot as { legacyUserId?: unknown; discordAccountId?: unknown; eligibleStoriesCount?: unknown; assignedStoriesCount?: unknown; syncStatus?: unknown; lastSyncedAt?: unknown; lastError?: unknown; }; const syncStatus = parseStoriesRoleSyncStatus(parsed.syncStatus); if ( typeof parsed.legacyUserId !== "number" || !Number.isInteger(parsed.legacyUserId) ) { return json({ ok: false, error: "legacyUserId must be an integer" }, 400); } if ( parsed.discordAccountId !== null && parsed.discordAccountId !== undefined && typeof parsed.discordAccountId !== "string" ) { return json( { ok: false, error: "discordAccountId must be a string or null" }, 400, ); } if ( parsed.eligibleStoriesCount !== null && parsed.eligibleStoriesCount !== undefined && (typeof parsed.eligibleStoriesCount !== "number" || !Number.isInteger(parsed.eligibleStoriesCount)) ) { return json( { ok: false, error: "eligibleStoriesCount must be an integer or null" }, 400, ); } if ( parsed.assignedStoriesCount !== null && parsed.assignedStoriesCount !== undefined && (typeof parsed.assignedStoriesCount !== "number" || !Number.isInteger(parsed.assignedStoriesCount)) ) { return json( { ok: false, error: "assignedStoriesCount must be an integer or null" }, 400, ); } if (!syncStatus) { return json({ ok: false, error: "syncStatus is invalid" }, 400); } if ( typeof parsed.lastSyncedAt !== "number" || !Number.isInteger(parsed.lastSyncedAt) ) { return json({ ok: false, error: "lastSyncedAt must be an integer" }, 400); } if ( parsed.lastError !== null && parsed.lastError !== undefined && typeof parsed.lastError !== "string" ) { return json( { ok: false, error: "lastError must be a string or null" }, 400, ); } snapshots.push({ legacyUserId: parsed.legacyUserId, discordAccountId: typeof parsed.discordAccountId === "string" ? parsed.discordAccountId : null, eligibleStoriesCount: typeof parsed.eligibleStoriesCount === "number" ? parsed.eligibleStoriesCount : null, assignedStoriesCount: typeof parsed.assignedStoriesCount === "number" ? parsed.assignedStoriesCount : null, syncStatus, lastSyncedAt: parsed.lastSyncedAt, lastError: typeof parsed.lastError === "string" ? parsed.lastError : null, }); } await ctx.runMutation( internal.discordRoleSync.replaceStoriesRoleSyncSnapshots, { snapshots, }, ); return json({ ok: true, count: snapshots.length }); }); ================================================ FILE: convex/discordData.ts ================================================ import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; import { internalQuery, type QueryCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; const contributorUserValidator = v.object({ legacyUserId: v.number(), author: v.string(), discordAccountId: v.union(v.string(), v.null()), }); const publicStoriesPageValidator = v.object({ page: v.array(v.number()), isDone: v.boolean(), continueCursor: v.string(), }); const approvalsPageValidator = v.object({ page: v.array( v.object({ id: v.id("story_approval"), legacyUserId: v.number(), storyId: v.number(), date: v.number(), }), ), isDone: v.boolean(), continueCursor: v.string(), }); type AdapterWhere = Array<{ field: string; operator?: "eq" | "in"; value: string | Array; }>; type BetterAuthModel = "user" | "account"; type AuthUserRow = { _id?: string | null; userId?: string | null; name?: string | null; role?: string | null; }; type AuthAccountRow = { userId?: string | null; accountId?: string | null; }; type PaginatedAdapterResponse = { page: T[]; isDone?: boolean; continueCursor?: string | null; }; async function findManyAll( ctx: QueryCtx, model: BetterAuthModel, where: AdapterWhere, ): Promise { let cursor: string | null = null; const rows: T[] = []; while (true) { const page = (await ctx.runQuery(components.betterAuth.adapter.findMany, { model, where, paginationOpts: { cursor, numItems: 200 }, })) as PaginatedAdapterResponse; rows.push(...page.page); if (page.isDone) break; cursor = page.continueCursor ?? null; if (!cursor) break; } return rows; } function chunk(items: T[], size: number): T[][] { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } async function getContributorAndAdminUsers(ctx: QueryCtx) { const [contributors, admins] = await Promise.all([ findManyAll(ctx, "user", [ { field: "role", operator: "eq", value: "contributor" }, ]), findManyAll(ctx, "user", [ { field: "role", operator: "eq", value: "admin" }, ]), ]); const usersByAuthId = new Map(); for (const user of [...contributors, ...admins]) { if (typeof user._id !== "string" || user._id.length === 0) continue; usersByAuthId.set(user._id, user); } return usersByAuthId; } export const getContributorDiscordLinks = internalQuery({ args: {}, returns: v.array(contributorUserValidator), handler: async (ctx) => { const usersByAuthId = await getContributorAndAdminUsers(ctx); const authUserIds = Array.from(usersByAuthId.keys()); if (authUserIds.length === 0) return []; const accounts: AuthAccountRow[] = []; for (const userIds of chunk(authUserIds, 100)) { const rows = await findManyAll(ctx, "account", [ { field: "providerId", operator: "eq", value: "discord" }, { field: "userId", operator: "in", value: userIds }, ]); accounts.push(...rows); } const discordAccountIdByUserId = new Map(); for (const account of accounts) { if ( typeof account.userId !== "string" || typeof account.accountId !== "string" ) { continue; } discordAccountIdByUserId.set(account.userId, account.accountId); } return authUserIds .map((authUserId) => { const user = usersByAuthId.get(authUserId); const discordAccountId = discordAccountIdByUserId.get(authUserId); if (!user || typeof user.name !== "string") { return null; } const legacyUserId = Number.parseInt(user.userId ?? "", 10); if (!Number.isFinite(legacyUserId)) { return null; } return { legacyUserId, author: user.name, discordAccountId: typeof discordAccountId === "string" ? discordAccountId : null, }; }) .filter( ( user, ): user is { legacyUserId: number; author: string; discordAccountId: string | null; } => user !== null, ); }, }); export const getPublicStoryIdsPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, }, returns: publicStoriesPageValidator, handler: async (ctx, args) => { const page = await ctx.db .query("stories") .withIndex("by_public", (q) => q.eq("public", true).eq("deleted", false)) .paginate(args.paginationOpts); return { page: page.page .map((story) => story.legacyId) .filter((legacyId): legacyId is number => typeof legacyId === "number"), isDone: page.isDone, continueCursor: page.continueCursor, }; }, }); export const getApprovalPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, sinceDate: v.optional(v.number()), }, returns: approvalsPageValidator, handler: async (ctx, args) => { const page = await ctx.db .query("story_approval") .withIndex("by_date", (q) => q.gte("date", args.sinceDate ?? 0)) .paginate(args.paginationOpts); const legacyUserIds = Array.from( new Set( page.page .map((approval) => approval.legacyUserId) .filter( (legacyUserId): legacyUserId is number => typeof legacyUserId === "number", ), ), ); const allowedLegacyUserIds = new Set(); for (const legacyIds of chunk(legacyUserIds, 100)) { const users = (await ctx.runQuery( components.betterAuth.adapter.findMany, { model: "user", where: [ { field: "userId", operator: "in", value: legacyIds.map(String) }, ], paginationOpts: { cursor: null, numItems: legacyIds.length + 20 }, }, )) as PaginatedAdapterResponse; for (const user of users.page) { if (user.role !== "contributor" && user.role !== "admin") continue; const legacyUserId = Number.parseInt(user.userId ?? "", 10); if (Number.isFinite(legacyUserId)) { allowedLegacyUserIds.add(legacyUserId); } } } const storyIds = Array.from( new Set(page.page.map((approval) => approval.storyId)), ); const storyMetaById = new Map, number>(); for (const storyId of storyIds) { const story = await ctx.db.get(storyId); if (story && typeof story.legacyId === "number") { storyMetaById.set(storyId, story.legacyId); } } return { page: page.page .map((approval) => { if ( typeof approval.legacyUserId !== "number" || !allowedLegacyUserIds.has(approval.legacyUserId) ) { return null; } const storyLegacyId = storyMetaById.get(approval.storyId); if (typeof storyLegacyId !== "number") return null; return { id: approval._id, legacyUserId: approval.legacyUserId, storyId: storyLegacyId, date: approval.date, }; }) .filter( ( approval, ): approval is { id: Id<"story_approval">; legacyUserId: number; storyId: number; date: number; } => approval !== null, ), isDone: page.isDone, continueCursor: page.continueCursor, }; }, }); ================================================ FILE: convex/discordRoleSync.ts ================================================ import { internalMutation } from "./_generated/server"; import { v } from "convex/values"; const syncStatusValidator = v.union( v.literal("assigned"), v.literal("up_to_date"), v.literal("no_milestone"), v.literal("not_linked"), v.literal("member_not_found"), v.literal("error"), ); const storiesRoleSnapshotValidator = v.object({ legacyUserId: v.number(), discordAccountId: v.union(v.string(), v.null()), eligibleStoriesCount: v.union(v.number(), v.null()), assignedStoriesCount: v.union(v.number(), v.null()), syncStatus: syncStatusValidator, lastSyncedAt: v.number(), lastError: v.union(v.string(), v.null()), }); export const replaceStoriesRoleSyncSnapshots = internalMutation({ args: { snapshots: v.array(storiesRoleSnapshotValidator), }, returns: v.null(), handler: async (ctx, args) => { const existingRows = await ctx.db .query("discord_stories_role_sync") .collect(); const existingByLegacyUserId = new Map( existingRows.map((row) => [row.legacyUserId, row]), ); for (const snapshot of args.snapshots) { const existing = existingByLegacyUserId.get(snapshot.legacyUserId); if (existing) { await ctx.db.patch(existing._id, snapshot); continue; } await ctx.db.insert("discord_stories_role_sync", snapshot); } return null; }, }); ================================================ FILE: convex/editorRead.ts ================================================ import { query, type QueryCtx } from "./_generated/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { courseContributorValidator } from "./lib/courseContributors"; type LanguageDoc = Doc<"languages">; type CourseDoc = Doc<"courses">; type StoryDoc = Doc<"stories">; type AvatarDoc = Doc<"avatars">; type AvatarMappingDoc = Doc<"avatar_mappings">; const editorCourseValidator = v.union( v.object({ id: v.number(), short: v.union(v.string(), v.null()), about: v.union(v.string(), v.null()), official: v.boolean(), count: v.number(), public: v.boolean(), fromLanguageId: v.id("languages"), from_language: v.number(), from_language_short: v.string(), from_language_name: v.string(), learningLanguageId: v.id("languages"), learning_language: v.number(), learning_language_short: v.string(), learning_language_name: v.string(), contributors: v.array(courseContributorValidator), contributors_past: v.array(courseContributorValidator), todo_count: v.number(), }), v.null(), ); function toNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return undefined; } function toLanguage(language: LanguageDoc) { return { languageId: language._id, id: language.legacyId, name: language.name, short: language.short, speaker: language.speaker ?? null, default_text: language.default_text ?? "", tts_replace: language.tts_replace ?? null, public: language.public, rtl: language.rtl, }; } function toCourse( course: CourseDoc, languageById: Map, LanguageDoc>, ) { const learningLanguage = languageById.get(course.learningLanguageId); const fromLanguage = languageById.get(course.fromLanguageId); return { id: course.legacyId, short: course.short ?? null, about: course.about ?? null, official: course.official, count: course.count ?? 0, public: course.public, fromLanguageId: course.fromLanguageId, from_language: fromLanguage?.legacyId ?? 0, from_language_short: fromLanguage?.short ?? "", from_language_name: fromLanguage?.name ?? course.from_language_name ?? "", learningLanguageId: course.learningLanguageId, learning_language: learningLanguage?.legacyId ?? 0, learning_language_short: learningLanguage?.short ?? "", learning_language_name: learningLanguage?.name ?? course.learning_language_name ?? "", contributors: course.contributors ?? [], contributors_past: course.contributors_past ?? [], todo_count: course.todo_count ?? 0, }; } async function getCourseByIdentifier(ctx: QueryCtx, identifier: string) { const isNumericIdentifier = /^\d+$/.test(identifier); if (isNumericIdentifier) { const numeric = Number(identifier); const byId = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", numeric)) .unique(); if (byId) return byId; } return await ctx.db .query("courses") .withIndex("by_short", (q) => q.eq("short", identifier)) .unique(); } async function getUserNameByLegacyId(ctx: QueryCtx, legacyIds: number[]) { const uniqueLegacyIds = Array.from(new Set(legacyIds)); if (!uniqueLegacyIds.length) return new Map(); const userIds = uniqueLegacyIds.map((legacyId) => String(legacyId)); const users = (await ctx.runQuery(components.betterAuth.adapter.findMany, { model: "user", where: [{ field: "userId", operator: "in", value: userIds }], paginationOpts: { cursor: null, numItems: userIds.length + 10 }, })) as { page: Array<{ userId?: string | null; name?: string | null }>; }; const map = new Map(); for (const user of users.page) { const legacyId = Number.parseInt(user.userId ?? "", 10); if (!Number.isFinite(legacyId) || !user.name) continue; map.set(legacyId, user.name); } return map; } async function getUserNameByAuthDocId(ctx: QueryCtx, authDocIds: string[]) { const uniqueAuthDocIds = Array.from( new Set(authDocIds.map((id) => id.trim()).filter(Boolean)), ); if (!uniqueAuthDocIds.length) return new Map(); const map = new Map(); const byUserId = (await ctx.runQuery(components.betterAuth.adapter.findMany, { model: "user", where: [{ field: "userId", operator: "in", value: uniqueAuthDocIds }], paginationOpts: { cursor: null, numItems: uniqueAuthDocIds.length + 10 }, })) as { page: Array<{ userId?: string | null; name?: string | null }>; }; for (const user of byUserId.page) { const userId = user.userId?.trim(); if (!userId || !user.name) continue; map.set(userId, user.name); } const unresolvedIds = uniqueAuthDocIds.filter((id) => !map.has(id)); if (!unresolvedIds.length) return map; // Fallback for legacy rows that still store Better Auth document IDs. const byDocId = await Promise.all( unresolvedIds.map(async (id) => { try { const user = (await ctx.runQuery(components.betterAuth.adapter.get, { id, })) as { name?: string | null } | null; return { id, name: user?.name ?? null }; } catch { return { id, name: null }; } }), ); for (const result of byDocId) { if (!result.name) continue; map.set(result.id, result.name); } return map; } async function buildAvatarRows( ctx: QueryCtx, language: LanguageDoc, avatars?: AvatarDoc[], mappings?: AvatarMappingDoc[], ) { const avatarRows = avatars ?? (await ctx.db.query("avatars").collect()); const mappingRows = mappings ?? (await ctx.db .query("avatar_mappings") .withIndex("by_language_id", (q) => q.eq("languageId", language._id)) .collect()); const mappingByAvatar = new Map, AvatarMappingDoc>(); for (const mapping of mappingRows) { mappingByAvatar.set(mapping.avatarId, mapping); } return avatarRows .filter((avatar: AvatarDoc) => avatar.link !== "[object Object]") .map((avatar: AvatarDoc) => { const mapping = mappingByAvatar.get(avatar._id); return { id: mapping?.legacyId ?? null, avatar_id: avatar.legacyId, language_id: language.legacyId, name: mapping?.name ?? avatar.name ?? "", link: avatar.link, speaker: mapping?.speaker ?? "", }; }) .sort( (a: { avatar_id: number }, b: { avatar_id: number }) => a.avatar_id - b.avatar_id, ); } export const getEditorSidebarData = query({ args: {}, handler: async (ctx) => { const courseRows = await ctx.db.query("courses").collect(); const referencedLanguageIds = Array.from( new Set( courseRows.flatMap((course) => [ course.learningLanguageId, course.fromLanguageId, ]), ), ); const languageRows = await Promise.all( referencedLanguageIds.map((languageId) => ctx.db.get(languageId)), ); const languageById = new Map, LanguageDoc>(); for (const language of languageRows) { if (!language) continue; languageById.set(language._id, language); } const courses = courseRows .map((course) => toCourse(course, languageById)) .sort((a, b) => b.count - a.count); return { courses }; }, }); export const getEditorCourseByIdentifier = query({ args: { identifier: v.string() }, returns: editorCourseValidator, handler: async (ctx, args) => { const course = await getCourseByIdentifier(ctx, args.identifier); if (!course) return null; const [learningLanguage, fromLanguage] = await Promise.all([ ctx.db.get(course.learningLanguageId) as Promise, ctx.db.get(course.fromLanguageId) as Promise, ]); const contributorLists = { contributors: course.contributorDetails ?? [], contributors_past: course.contributorDetailsPast ?? [], }; return { id: course.legacyId, short: course.short ?? null, about: course.about ?? null, official: course.official, count: course.count ?? 0, public: course.public, fromLanguageId: course.fromLanguageId, from_language: fromLanguage?.legacyId ?? 0, from_language_short: fromLanguage?.short ?? "", from_language_name: fromLanguage?.name ?? course.from_language_name ?? "", learningLanguageId: course.learningLanguageId, learning_language: learningLanguage?.legacyId ?? 0, learning_language_short: learningLanguage?.short ?? "", learning_language_name: learningLanguage?.name ?? course.learning_language_name ?? "", contributors: contributorLists.contributors, contributors_past: contributorLists.contributors_past, todo_count: course.todo_count ?? 0, }; }, }); export const getEditorStoriesByCourseLegacyId = query({ args: { identifier: v.string() }, handler: async (ctx, args) => { const timerBase = `editorRead:getEditorStoriesByCourseLegacyId:course:${args.identifier}`; const storiesTimer = `${timerBase}:stories`; const imagesTimer = `${timerBase}:images`; const authorsTimer = `${timerBase}:authors`; console.time(timerBase); const course = await getCourseByIdentifier(ctx, args.identifier); if (!course) { console.timeEnd(timerBase); console.log( `[editorRead:getEditorStoriesByCourseLegacyId] course=${args.identifier} not_found`, ); return []; } console.time(storiesTimer); const storyRows = await ctx.db .query("stories") .withIndex("by_set", (q) => q.eq("courseId", course._id)) .collect(); console.timeEnd(storiesTimer); const stories = storyRows.filter((story) => !story.deleted); console.time(imagesTimer); const imageIds = Array.from( new Set( stories .map((story) => story.imageId) .filter((id): id is Id<"images"> => Boolean(id)), ), ); const images = await Promise.all(imageIds.map((id) => ctx.db.get(id))); const imageById = new Map, Doc<"images">>(); images.forEach((image) => { if (!image) return; imageById.set(image._id, image); }); console.timeEnd(imagesTimer); console.time(authorsTimer); const authorLegacyIds = Array.from( new Set( stories .flatMap((story) => [ toNumber(story.authorId), toNumber(story.authorChangeId), ]) .filter((id): id is number => id !== undefined), ), ); const authorAuthDocIds = Array.from( new Set( stories .flatMap((story) => [story.authorId, story.authorChangeId]) .filter( (id): id is string => typeof id === "string" && id.trim().length > 0 && toNumber(id) === undefined, ), ), ); const [nameByLegacyId, nameByAuthDocId] = await Promise.all([ getUserNameByLegacyId(ctx, authorLegacyIds), getUserNameByAuthDocId(ctx, authorAuthDocIds), ]); console.timeEnd(authorsTimer); const result = stories.map((story: StoryDoc) => { const authorId = toNumber(story.authorId); const authorChangeId = toNumber(story.authorChangeId); const rawAuthorId = typeof story.authorId === "string" ? story.authorId.trim() : story.authorId; const rawAuthorChangeId = typeof story.authorChangeId === "string" ? story.authorChangeId.trim() : story.authorChangeId; const image = story.imageId ? imageById.get(story.imageId) : undefined; const approvalCount = story.approvalCount; const derivedStatus = approvalCount === undefined ? story.status : approvalCount === 0 ? "draft" : approvalCount === 1 ? "feedback" : "finished"; return { id: story.legacyId ?? 0, name: story.name, course_id: course.legacyId, image: image?.legacyId ?? "", set_id: story.set_id ?? 0, set_index: story.set_index ?? 0, date: story.date ?? story._creationTime, change_date: story.change_date, status: derivedStatus, public: story.public, todo_count: story.todo_count ?? 0, approvalCount: approvalCount ?? 0, author: typeof authorId === "number" ? (nameByLegacyId.get(authorId) ?? `User ${authorId}`) : typeof rawAuthorId === "string" && rawAuthorId.length > 0 ? (nameByAuthDocId.get(rawAuthorId) ?? `User ${rawAuthorId}`) : "Unknown", author_change: typeof authorChangeId === "number" ? (nameByLegacyId.get(authorChangeId) ?? `User ${authorChangeId}`) : typeof rawAuthorChangeId === "string" && rawAuthorChangeId.length > 0 ? (nameByAuthDocId.get(rawAuthorChangeId) ?? `User ${rawAuthorChangeId}`) : null, }; }); console.timeEnd(timerBase); console.log( `[editorRead:getEditorStoriesByCourseLegacyId] course=${args.identifier} totalStories=${storyRows.length} visibleStories=${stories.length} uniqueImages=${imageIds.length} uniqueLegacyAuthors=${authorLegacyIds.length} uniqueAuthDocAuthors=${authorAuthDocIds.length}`, ); return result; }, }); export const getEditorCourseImport = query({ args: { courseLegacyId: v.number(), fromLegacyId: v.number(), }, handler: async (ctx, args) => { const [toCourse, fromCourse] = await Promise.all([ ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.courseLegacyId)) .unique(), ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.fromLegacyId)) .unique(), ]); if (!toCourse || !fromCourse) return []; const [sourceStories, targetStories] = await Promise.all([ ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", fromCourse._id)) .collect(), ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", toCourse._id)) .collect(), ]); const activeSourceStories = sourceStories .filter((story) => !story.deleted) .sort((a, b) => { const setA = a.set_id ?? 0; const setB = b.set_id ?? 0; if (setA !== setB) return setA - setB; return (a.set_index ?? 0) - (b.set_index ?? 0); }); const targetCountByDuoId = new Map(); for (const story of targetStories) { if (story.deleted) continue; if (!story.duo_id) continue; targetCountByDuoId.set( story.duo_id, (targetCountByDuoId.get(story.duo_id) ?? 0) + 1, ); } const imageIds = Array.from( new Set( activeSourceStories .map((story) => story.imageId) .filter((id): id is Id<"images"> => Boolean(id)), ), ); const images = await Promise.all(imageIds.map((id) => ctx.db.get(id))); const imageById = new Map, Doc<"images">>(); for (const image of images) { if (!image) continue; imageById.set(image._id, image); } return activeSourceStories.map((story) => { const image = story.imageId ? imageById.get(story.imageId) : undefined; return { id: story.legacyId ?? 0, set_id: story.set_id ?? 0, set_index: story.set_index ?? 0, name: story.name, image_done: image?.gilded ?? "", image: image?.active ?? "", copies: String(targetCountByDuoId.get(story.duo_id ?? "") ?? 0), }; }); }, }); export const resolveEditorLanguage = query({ args: { identifier: v.string() }, handler: async (ctx, args) => { const numeric = toNumber(args.identifier); if (numeric !== undefined) { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", numeric)) .unique(); if (!language) return null; return { language: toLanguage(language), course: null, language2: null, }; } const course = await ctx.db .query("courses") .withIndex("by_short", (q) => q.eq("short", args.identifier)) .unique(); if (course) { const [language, language2] = await Promise.all([ ctx.db.get(course.learningLanguageId), ctx.db.get(course.fromLanguageId), ]); if (!language) return null; return { language: toLanguage(language), course: { learning_language: language.legacyId, from_language: language2?.legacyId ?? 0, short: course.short ?? "", }, language2: language2 ? toLanguage(language2) : null, }; } const language = await ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", args.identifier)) .unique(); if (!language) return null; return { language: toLanguage(language), course: null, language2: null, }; }, }); export const getEditorSpeakersByLanguageLegacyId = query({ args: { languageLegacyId: v.number() }, handler: async (ctx, args) => { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.languageLegacyId)) .unique(); if (!language) return []; const speakers = await ctx.db .query("speakers") .withIndex("by_language_id", (q) => q.eq("languageId", language._id)) .collect(); return speakers .map((speaker) => ({ id: speaker.legacyId ?? 0, language_id: language.legacyId, speaker: speaker.speaker, gender: speaker.gender, type: speaker.type, service: speaker.service, })) .sort((a, b) => a.speaker.localeCompare(b.speaker)); }, }); export const getEditorAvatarNamesByLanguageLegacyId = query({ args: { languageLegacyId: v.number() }, handler: async (ctx, args) => { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.languageLegacyId)) .unique(); if (!language) return []; return await buildAvatarRows(ctx, language); }, }); export const getEditorLocalizationRowsByLanguageLegacyId = query({ args: { languageLegacyId: v.number() }, handler: async (ctx, args) => { const [englishLanguage, targetLanguage] = await Promise.all([ ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", "en")) .unique(), ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.languageLegacyId), ) .unique(), ]); if (!englishLanguage || !targetLanguage) return []; const englishRows = await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", englishLanguage._id), ) .collect(); const targetRows = englishLanguage._id === targetLanguage._id ? englishRows : await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", targetLanguage._id), ) .collect(); const targetByTag = new Map(); for (const row of targetRows) { if (!row.tag) continue; targetByTag.set(row.tag, row.text); } return englishRows .filter((row) => Boolean(row.tag)) .map((row) => ({ tag: row.tag, text_en: row.text, text: targetByTag.get(row.tag) ?? null, })); }, }); export const getEditorStoryPageData = query({ args: { storyId: v.number() }, handler: async (ctx, args) => { const identity = (await ctx.auth.getUserIdentity()) as { role?: string | null; } | null; const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.storyId)) .unique(); if (!story || story.deleted) return null; const [course, storyContent] = await Promise.all([ ctx.db.get(story.courseId), ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .unique(), ]); if (!course || !storyContent) return null; const [learningLanguage, fromLanguage, image] = await Promise.all([ ctx.db.get(course.learningLanguageId), ctx.db.get(course.fromLanguageId), story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null), ]); if (!learningLanguage || !fromLanguage) return null; return { isAdmin: identity?.role === "admin", story_data: { id: story.legacyId ?? 0, official: course.official, course_id: course.legacyId, duo_id: story.duo_id ?? "", image: image?.legacyId ?? "", name: story.name, set_id: story.set_id ?? 0, set_index: story.set_index ?? 0, text: storyContent.text, short: course.short ?? "", learning_language: learningLanguage.legacyId, from_language: fromLanguage.legacyId, }, }; }, }); export const getEditorImageByLegacyId = query({ args: { legacyImageId: v.string() }, handler: async (ctx, args) => { const image = await ctx.db .query("images") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyImageId)) .unique(); if (!image) return null; return { id: image.legacyId, active: image.active, gilded: image.gilded, locked: image.locked, active_lip: image.active_lip, gilded_lip: image.gilded_lip, }; }, }); export const getEditorLanguageByLegacyId = query({ args: { legacyLanguageId: v.number() }, handler: async (ctx, args) => { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyLanguageId)) .unique(); if (!language) return null; return toLanguage(language); }, }); ================================================ FILE: convex/editorSideEffects.ts ================================================ "use node"; import { Octokit } from "@octokit/rest"; import { PostHog } from "posthog-node"; import { internalAction } from "./_generated/server"; import { v } from "convex/values"; const CONTENT_REPOSITORY = "rgerum/unofficial-duolingo-stories-content"; let octokitClient: Octokit | null = null; function toBase64Utf8(value: string) { const bytes = new TextEncoder().encode(value); let binary = ""; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); } return btoa(binary); } function getOctokit() { const token = process.env.GITHUB_REPO_TOKEN; if (!token) return null; if (!octokitClient) { octokitClient = new Octokit({ auth: token }); } return octokitClient; } async function uploadWithDiffToGithub(args: { repository: string; content?: string; path: string; authorName: string; authorEmail: string; gitMessage: string; }) { const octokit = getOctokit(); if (!octokit) return { ok: false as const, reason: "missing_token" as const }; const [owner, repo] = args.repository.split("/"); let fileSha: string | undefined; try { const { data } = await octokit.repos.getContent({ owner, repo, path: args.path, }); if (!Array.isArray(data) && "sha" in data) { fileSha = data.sha; } } catch { // Missing file is expected for first write. } const author = { name: args.authorName, email: args.authorEmail, }; if (!args.content) { if (!fileSha) return { ok: true as const }; await octokit.repos.deleteFile({ owner, repo, path: args.path, message: args.gitMessage, sha: fileSha, committer: author, author, }); return { ok: true as const }; } await octokit.repos.createOrUpdateFileContents({ owner, repo, path: args.path, message: args.gitMessage, content: toBase64Utf8(args.content), sha: fileSha, committer: author, author, }); return { ok: true as const }; } function getPosthogClient() { const apiKey = process.env.POSTHOG_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_KEY; if (!apiKey) return null; return new PostHog(apiKey, { host: process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST, flushAt: 1, flushInterval: 0, }); } export const onStorySaved = internalAction({ args: { operationKey: v.string(), storyId: v.number(), storyName: v.string(), courseId: v.number(), text: v.string(), todoCount: v.number(), actorName: v.string(), actorLegacyUserId: v.number(), }, returns: v.object({ githubUploaded: v.boolean(), posthogTracked: v.boolean(), }), handler: async (_ctx, args) => { let githubUploaded = false; let posthogTracked = false; const uploadResult = await uploadWithDiffToGithub({ repository: CONTENT_REPOSITORY, content: args.text, path: `${args.courseId}/${args.storyId}.txt`, authorName: args.actorName, authorEmail: `${args.actorName}@carex.uberspace.de`, gitMessage: `updated ${args.storyName} in course ${args.courseId}`, }).catch((error) => { console.error("editorSideEffects:onStorySaved:github", error); return { ok: false as const }; }); githubUploaded = uploadResult.ok; const posthog = getPosthogClient(); if (posthog) { try { posthog.capture({ distinctId: args.actorName || `user_${args.actorLegacyUserId}`, event: "story_saved", properties: { story_id: args.storyId, story_name: args.storyName, course_id: args.courseId, todo_count: args.todoCount, editor_username: args.actorName, }, }); await posthog.shutdown(); posthogTracked = true; } catch (error) { console.error("editorSideEffects:onStorySaved:posthog", error); } } return { githubUploaded, posthogTracked }; }, }); export const onStoryDeleted = internalAction({ args: { operationKey: v.string(), storyId: v.number(), storyName: v.string(), courseId: v.number(), actorName: v.string(), actorLegacyUserId: v.number(), }, returns: v.object({ githubUploaded: v.boolean(), posthogTracked: v.boolean(), }), handler: async (_ctx, args) => { let githubUploaded = false; let posthogTracked = false; const uploadResult = await uploadWithDiffToGithub({ repository: CONTENT_REPOSITORY, path: `${args.courseId}/${args.storyId}.txt`, authorName: args.actorName, authorEmail: `${args.actorName}@carex.uberspace.de`, gitMessage: `delete ${args.storyName} from course ${args.courseId}`, }).catch((error) => { console.error("editorSideEffects:onStoryDeleted:github", error); return { ok: false as const }; }); githubUploaded = uploadResult.ok; const posthog = getPosthogClient(); if (posthog) { try { posthog.capture({ distinctId: args.actorName || `user_${args.actorLegacyUserId}`, event: "story_deleted", properties: { story_id: args.storyId, story_name: args.storyName, course_id: args.courseId, editor_username: args.actorName, }, }); await posthog.shutdown(); posthogTracked = true; } catch (error) { console.error("editorSideEffects:onStoryDeleted:posthog", error); } } return { githubUploaded, posthogTracked }; }, }); export const onStoryImported = internalAction({ args: { operationKey: v.string(), storyId: v.number(), storyName: v.string(), courseId: v.number(), text: v.string(), actorName: v.string(), actorLegacyUserId: v.number(), }, returns: v.object({ githubUploaded: v.boolean(), }), handler: async (_ctx, args) => { const uploadResult = await uploadWithDiffToGithub({ repository: CONTENT_REPOSITORY, content: args.text, path: `${args.courseId}/${args.storyId}.txt`, authorName: args.actorName, authorEmail: `${args.actorName}@carex.uberspace.de`, gitMessage: `added ${args.storyName} in course ${args.courseId}`, }).catch((error) => { console.error("editorSideEffects:onStoryImported:github", error); return { ok: false as const }; }); return { githubUploaded: uploadResult.ok }; }, }); export const onStoryApprovalToggled = internalAction({ args: { operationKey: v.string(), storyId: v.number(), action: v.union(v.literal("added"), v.literal("deleted")), count: v.number(), storyStatus: v.union( v.literal("draft"), v.literal("feedback"), v.literal("finished"), ), finishedInSet: v.number(), publishedCount: v.number(), actorName: v.string(), actorLegacyUserId: v.number(), }, returns: v.object({ posthogTracked: v.boolean(), }), handler: async (_ctx, args) => { const posthog = getPosthogClient(); if (!posthog) return { posthogTracked: false }; try { posthog.capture({ distinctId: args.actorName || `user_${args.actorLegacyUserId}`, event: "story_approved", properties: { story_id: args.storyId, action: args.action, approval_count: args.count, story_status: args.storyStatus, finished_in_set: args.finishedInSet, stories_published: args.publishedCount, }, }); await posthog.shutdown(); return { posthogTracked: true }; } catch (error) { console.error("editorSideEffects:onStoryApprovalToggled:posthog", error); return { posthogTracked: false }; } }, }); ================================================ FILE: convex/http.ts ================================================ import { httpRouter } from "convex/server"; import { backfillCourseContributorDetailsHttp } from "./courseContributorBackfill"; import { getDiscordCombineData, setStoriesRoleSyncStatus, setContributorWriteByDiscordAccountId, } from "./discordBot"; import { authComponent, createAuth } from "./betterAuth/auth"; import { backfillDiscordUserImagesHttp as backfillDiscordUserImagesHandler } from "./discordAvatarSync"; const http = httpRouter(); authComponent.registerRoutes(http, createAuth); http.route({ path: "/discord/set-contributor-write", method: "POST", handler: setContributorWriteByDiscordAccountId, }); http.route({ path: "/discord/combine-data", method: "POST", handler: getDiscordCombineData, }); http.route({ path: "/discord/set-stories-role-status", method: "POST", handler: setStoriesRoleSyncStatus, }); http.route({ path: "/admin/backfill-discord-avatars", method: "POST", handler: backfillDiscordUserImagesHandler, }); http.route({ path: "/admin/backfill-course-contributors", method: "POST", handler: backfillCourseContributorDetailsHttp, }); export default http; ================================================ FILE: convex/landing.ts ================================================ import { query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { courseContributorValidator } from "./lib/courseContributors"; const courseListItemValidator = v.object({ id: v.number(), short: v.string(), name: v.string(), count: v.number(), about: v.string(), tags: v.array(v.string()), from_language: v.number(), fromLanguageId: v.id("languages"), from_language_name: v.string(), learning_language: v.number(), learningLanguageId: v.id("languages"), learning_language_name: v.string(), }); const landingCourseItemValidator = v.object({ id: v.number(), short: v.string(), name: v.string(), count: v.number(), learningLanguage: v.object({ id: v.id("languages"), short: v.string(), flag: v.optional(v.union(v.number(), v.string())), flag_file: v.optional(v.string()), }), }); const landingGroupValidator = v.object({ fromLanguageId: v.id("languages"), fromLanguageName: v.string(), labels: v.object({ storiesFor: v.string(), nStoriesTemplate: v.string(), }), courses: v.array(landingCourseItemValidator), }); const landingPageDataValidator = v.object({ stats: v.object({ courseCount: v.number(), storyCount: v.number(), }), groups: v.array(landingGroupValidator), }); type LandingCourseItem = { id: number; short: string; name: string; count: number; learningLanguage: { id: Id<"languages">; short: string; flag: number | string | undefined; flag_file: string | undefined; }; }; type LandingGroup = { fromLanguageId: Id<"languages">; fromLanguageName: string; labels: { storiesFor: string; nStoriesTemplate: string; }; courses: LandingCourseItem[]; }; export const getPublicCourseList = query({ args: {}, returns: v.array(courseListItemValidator), handler: async (ctx) => { const courses = await ctx.db .query("courses") .withIndex("by_public", (q) => q.eq("public", true)) .collect(); const languageIds = new Set>(); for (const course of courses) { languageIds.add(course.fromLanguageId); languageIds.add(course.learningLanguageId); } const languageRows = await Promise.all( Array.from(languageIds).map(async (languageId) => ({ languageId, language: await ctx.db.get(languageId), })), ); const legacyLanguageIdByConvexId = new Map, number>(); const languageNameByConvexId = new Map, string>(); for (const row of languageRows) { if (!row.language) continue; legacyLanguageIdByConvexId.set(row.languageId, row.language.legacyId); languageNameByConvexId.set(row.languageId, row.language.name); } return courses .map((course) => { if (!course.short) return null; const fromLanguageName = languageNameByConvexId.get(course.fromLanguageId) ?? ""; const learningLanguageName = languageNameByConvexId.get(course.learningLanguageId) ?? ""; return { id: course.legacyId, short: course.short, name: course.name && course.name.trim().length > 0 ? course.name : learningLanguageName, count: course.count ?? 0, about: course.about ?? "", tags: course.tags ?? [], from_language: legacyLanguageIdByConvexId.get(course.fromLanguageId) ?? 0, fromLanguageId: course.fromLanguageId as Id<"languages">, from_language_name: fromLanguageName, learning_language: legacyLanguageIdByConvexId.get(course.learningLanguageId) ?? 0, learningLanguageId: course.learningLanguageId as Id<"languages">, learning_language_name: learningLanguageName, }; }) .filter( ( course, ): course is { id: number; short: string; name: string; count: number; about: string; tags: string[]; from_language: number; fromLanguageId: Id<"languages">; from_language_name: string; learning_language: number; learningLanguageId: Id<"languages">; learning_language_name: string; } => course !== null, ) .sort((a, b) => { const fromCmp = a.from_language_name.localeCompare( b.from_language_name, ); if (fromCmp !== 0) return fromCmp; return a.name.localeCompare(b.name); }); }, }); export const getPublicLandingPageData = query({ args: {}, returns: landingPageDataValidator, handler: async (ctx) => { const courses = await ctx.db .query("courses") .withIndex("by_public", (q) => q.eq("public", true)) .collect(); const englishLanguage = await ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", "en")) .unique(); const languageIds = new Set>(); for (const course of courses) { languageIds.add(course.fromLanguageId); languageIds.add(course.learningLanguageId); } if (englishLanguage?._id) { languageIds.add(englishLanguage._id); } const languageRows = await Promise.all( Array.from(languageIds).map(async (languageId) => ({ languageId, language: await ctx.db.get(languageId), })), ); const languageById = new Map< Id<"languages">, (typeof languageRows)[number]["language"] >(); for (const row of languageRows) { if (!row.language) continue; languageById.set(row.languageId, row.language); } const englishRows = englishLanguage ? await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", englishLanguage._id), ) .collect() : []; const englishLocalization = new Map(); for (const row of englishRows) { if (!row.tag || !row.text) continue; englishLocalization.set(row.tag, row.text); } const fromLanguageIds = Array.from( new Set(courses.map((course) => course.fromLanguageId)), ); const localizationByFromLanguageId = new Map< Id<"languages">, Map >(); await Promise.all( fromLanguageIds.map(async (fromLanguageId) => { const targetRows = englishLanguage?._id === fromLanguageId ? englishRows : await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", fromLanguageId), ) .collect(); const merged = new Map(englishLocalization); for (const row of targetRows) { if (!row.tag || !row.text) continue; merged.set(row.tag, row.text); } localizationByFromLanguageId.set(fromLanguageId, merged); }), ); const coursesByFromLanguageId = new Map< Id<"languages">, LandingCourseItem[] >(); for (const course of courses) { if (!course.short) continue; const fromLanguage = languageById.get(course.fromLanguageId); const learningLanguage = languageById.get(course.learningLanguageId); if (!fromLanguage || !learningLanguage) continue; const mappedCourse = { id: course.legacyId, short: course.short, name: course.name && course.name.trim().length > 0 ? course.name : learningLanguage.name, count: course.count ?? 0, learningLanguage: { id: course.learningLanguageId as Id<"languages">, short: learningLanguage.short, flag: learningLanguage.flag, flag_file: learningLanguage.flag_file, }, }; const list = coursesByFromLanguageId.get(course.fromLanguageId) ?? []; list.push(mappedCourse); coursesByFromLanguageId.set(course.fromLanguageId, list); } for (const [, groupCourses] of coursesByFromLanguageId) { groupCourses.sort((a, b) => a.name.localeCompare(b.name)); } const englishGroup = englishLanguage?._id ? coursesByFromLanguageId.get(englishLanguage._id) ? [englishLanguage._id] : [] : []; const otherGroupIds = Array.from(coursesByFromLanguageId.keys()) .filter((languageId) => languageId !== englishLanguage?._id) .sort((a, b) => { const nameA = languageById.get(a)?.name ?? ""; const nameB = languageById.get(b)?.name ?? ""; return nameA.localeCompare(nameB); }); const orderedGroupIds = [...englishGroup, ...otherGroupIds]; const groups = orderedGroupIds .map((fromLanguageId) => { const fromLanguage = languageById.get(fromLanguageId); const coursesInGroup = coursesByFromLanguageId.get(fromLanguageId) ?? []; if (!fromLanguage || coursesInGroup.length === 0) return null; const localization = localizationByFromLanguageId.get(fromLanguageId); return { fromLanguageId, fromLanguageName: fromLanguage.name, labels: { storiesFor: localization?.get("stories_for") ?? "Stories for", nStoriesTemplate: localization?.get("n_stories") ?? "$count stories", }, courses: coursesInGroup, }; }) .filter((group): group is LandingGroup => group !== null); let storyCount = 0; for (const group of groups) { for (const course of group.courses) { storyCount += course.count; } } return { stats: { courseCount: groups.reduce( (count, group) => count + group.courses.length, 0, ), storyCount, }, groups, }; }, }); const publicStoryListItemValidator = v.object({ id: v.number(), name: v.string(), course_id: v.number(), image: v.string(), set_id: v.number(), set_index: v.number(), active: v.string(), gilded: v.string(), active_lip: v.string(), gilded_lip: v.string(), }); const localizationEntryValidator = v.object({ tag: v.string(), text: v.string(), }); const publicCoursePageValidator = v.union( v.object({ ...courseListItemValidator.fields, contributors: v.array(courseContributorValidator), contributors_past: v.array(courseContributorValidator), stories: v.array(publicStoryListItemValidator), localization: v.array(localizationEntryValidator), }), v.null(), ); export const getPublicCoursePageData = query({ args: { short: v.string(), }, returns: publicCoursePageValidator, handler: async (ctx, args) => { const course = await ctx.db .query("courses") .withIndex("by_short", (q) => q.eq("short", args.short)) .unique(); if (!course || !course.public || !course.short) return null; const languageRows = await Promise.all([ ctx.db.get(course.fromLanguageId), ctx.db.get(course.learningLanguageId), ]); const fromLanguage = languageRows[0]; const learningLanguage = languageRows[1]; const legacyFromLanguageId = fromLanguage?.legacyId ?? 0; const legacyLearningLanguageId = learningLanguage?.legacyId ?? 0; const fromLanguageName = fromLanguage?.name ?? ""; const learningLanguageName = learningLanguage?.name ?? ""; const publicStories = await ctx.db .query("stories") .withIndex("by_course_public_deleted_set", (q) => q.eq("courseId", course._id).eq("public", true).eq("deleted", false), ) .collect(); const imageIds = Array.from( new Set( publicStories .map((story) => story.imageId) .filter((imageId): imageId is Id<"images"> => !!imageId), ), ); const imageRows = await Promise.all( imageIds.map(async (imageId) => ({ imageId, image: await ctx.db.get(imageId), })), ); const imageById = new Map< Id<"images">, (typeof imageRows)[number]["image"] >(); for (const row of imageRows) imageById.set(row.imageId, row.image); const englishLanguage = await ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", "en")) .unique(); const englishRows = englishLanguage ? await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", englishLanguage._id), ) .collect() : []; const targetRows = !fromLanguage || fromLanguage._id === englishLanguage?._id ? englishRows : await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", fromLanguage._id), ) .collect(); const localizationMap = new Map(); for (const row of englishRows) { if (!row.tag || !row.text) continue; localizationMap.set(row.tag, row.text); } for (const row of targetRows) { if (!row.tag || !row.text) continue; localizationMap.set(row.tag, row.text); } const mappedStories = publicStories .map((story) => { if (typeof story.legacyId !== "number") return null; const image = story.imageId ? imageById.get(story.imageId) : null; if (!image?.active || !image?.gilded) return null; return { id: story.legacyId, name: story.name, course_id: course.legacyId, image: image.legacyId, set_id: story.set_id ?? 0, set_index: story.set_index ?? 0, active: image.active, gilded: image.gilded, active_lip: image.active_lip, gilded_lip: image.gilded_lip, }; }) .filter( ( story, ): story is { id: number; name: string; course_id: number; image: string; set_id: number; set_index: number; active: string; gilded: string; active_lip: string; gilded_lip: string; } => story !== null, ) .sort((a, b) => { const setCmp = a.set_id - b.set_id; if (setCmp !== 0) return setCmp; return a.set_index - b.set_index; }); const contributorLists = { contributors: course.contributorDetails ?? [], contributors_past: course.contributorDetailsPast ?? [], }; return { id: course.legacyId, short: course.short, name: course.name && course.name.trim().length > 0 ? course.name : learningLanguageName, count: course.count ?? 0, about: course.about ?? "", tags: course.tags ?? [], from_language: legacyFromLanguageId, fromLanguageId: course.fromLanguageId as Id<"languages">, from_language_name: fromLanguageName, learning_language: legacyLearningLanguageId, learningLanguageId: course.learningLanguageId as Id<"languages">, learning_language_name: learningLanguageName, contributors: contributorLists.contributors, contributors_past: contributorLists.contributors_past, stories: mappedStories, localization: Array.from(localizationMap.entries()).map( ([tag, text]) => ({ tag, text, }), ), }; }, }); ================================================ FILE: convex/languageWrite.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import type { MutationCtx } from "./_generated/server"; import { requireAdmin, requireContributorOrAdmin } from "./lib/authorization"; function toLegacyLanguageResponse(row: { legacyId: number; name: string; short: string; flag?: number | string; flag_file?: string; speaker?: string; default_text?: string; tts_replace?: string; public: boolean; rtl: boolean; }) { return { id: row.legacyId, name: row.name, short: row.short, flag: typeof row.flag === "number" ? row.flag : Number.isFinite(Number(row.flag)) ? Number(row.flag) : null, flag_file: row.flag_file ?? null, speaker: row.speaker ?? null, default_text: row.default_text ?? "", tts_replace: row.tts_replace ?? "", public: row.public, rtl: row.rtl, }; } async function getLanguageByLegacyId( ctx: MutationCtx, legacyLanguageId: number, ) { return await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", legacyLanguageId)) .unique(); } async function getLanguageByShort(ctx: MutationCtx, short?: string | null) { if (!short) return null; return await ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", short)) .unique(); } async function getAvatarByLegacyId(ctx: MutationCtx, legacyAvatarId: number) { return await ctx.db .query("avatars") .withIndex("by_id_value", (q) => q.eq("legacyId", legacyAvatarId)) .unique(); } export const setDefaultText = mutation({ args: { legacyLanguageId: v.number(), default_text: v.string(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), name: v.string(), short: v.string(), flag: v.union(v.number(), v.null()), flag_file: v.union(v.string(), v.null()), speaker: v.union(v.string(), v.null()), default_text: v.string(), tts_replace: v.string(), public: v.boolean(), rtl: v.boolean(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const language = await getLanguageByLegacyId(ctx, args.legacyLanguageId); if (!language) { throw new Error(`Language ${args.legacyLanguageId} not found`); } const operationKey = args.operationKey ?? `language:${args.legacyLanguageId}:default_text:${Date.now()}`; await ctx.db.patch(language._id, { default_text: args.default_text, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); return toLegacyLanguageResponse({ ...language, default_text: args.default_text, }); }, }); export const setTtsReplace = mutation({ args: { legacyLanguageId: v.number(), tts_replace: v.string(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.number(), name: v.string(), short: v.string(), flag: v.union(v.number(), v.null()), flag_file: v.union(v.string(), v.null()), speaker: v.union(v.string(), v.null()), default_text: v.string(), tts_replace: v.string(), public: v.boolean(), rtl: v.boolean(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const language = await getLanguageByLegacyId(ctx, args.legacyLanguageId); if (!language) { throw new Error(`Language ${args.legacyLanguageId} not found`); } const operationKey = args.operationKey ?? `language:${args.legacyLanguageId}:tts_replace:${Date.now()}`; await ctx.db.patch(language._id, { tts_replace: args.tts_replace, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); return toLegacyLanguageResponse({ ...language, tts_replace: args.tts_replace, }); }, }); export const setAvatarSpeaker = mutation({ args: { legacyLanguageId: v.number(), legacyAvatarId: v.number(), name: v.string(), speaker: v.string(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.union(v.number(), v.null()), avatar_id: v.number(), language_id: v.number(), name: v.string(), speaker: v.string(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const [language, avatar] = await Promise.all([ getLanguageByLegacyId(ctx, args.legacyLanguageId), getAvatarByLegacyId(ctx, args.legacyAvatarId), ]); if (!language) { throw new Error(`Language ${args.legacyLanguageId} not found`); } if (!avatar) { throw new Error(`Avatar ${args.legacyAvatarId} not found`); } const operationKey = args.operationKey ?? `avatar_mapping:${args.legacyLanguageId}:${args.legacyAvatarId}:${Date.now()}`; const existing = await ctx.db .query("avatar_mappings") .withIndex("by_avatar_id_and_language_id", (q) => q.eq("avatarId", avatar._id).eq("languageId", language._id), ) .unique(); if (existing) { await ctx.db.patch(existing._id, { name: args.name, speaker: args.speaker, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } else { await ctx.db.insert("avatar_mappings", { avatarId: avatar._id, languageId: language._id, name: args.name, speaker: args.speaker, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } return { id: existing?.legacyId ?? null, avatar_id: args.legacyAvatarId, language_id: args.legacyLanguageId, name: args.name, speaker: args.speaker, }; }, }); export const upsertSpeakerFromVoice = mutation({ args: { localeShort: v.optional(v.string()), languageShort: v.optional(v.string()), speaker: v.string(), gender: v.string(), type: v.string(), service: v.string(), operationKey: v.optional(v.string()), }, returns: v.union( v.null(), v.object({ legacyLanguageId: v.number(), speaker: v.string(), gender: v.string(), type: v.string(), service: v.string(), }), ), handler: async (ctx, args) => { await requireAdmin(ctx); const language = (await getLanguageByShort(ctx, args.localeShort)) ?? (await getLanguageByShort(ctx, args.languageShort)); if (!language || language.legacyId === undefined) { return null; } const existing = ( await ctx.db .query("speakers") .withIndex("by_speaker", (q) => q.eq("speaker", args.speaker)) .collect() )[0]; const operationKey = args.operationKey ?? `speaker:${args.speaker}:upsert:${Date.now()}`; if (existing) { await ctx.db.patch(existing._id, { languageId: language._id, speaker: args.speaker, gender: args.gender, type: args.type, service: args.service, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } else { await ctx.db.insert("speakers", { languageId: language._id, speaker: args.speaker, gender: args.gender, type: args.type, service: args.service, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } return { legacyLanguageId: language.legacyId, speaker: args.speaker, gender: args.gender, type: args.type, service: args.service, }; }, }); ================================================ FILE: convex/lib/authorization.ts ================================================ import type { MutationCtx, QueryCtx } from "../_generated/server"; type AuthCtx = MutationCtx | QueryCtx; type RoleIdentity = { userId?: string | number | null; role?: string | null; } | null; async function getIdentity(ctx: AuthCtx) { return (await ctx.auth.getUserIdentity()) as RoleIdentity; } async function getRole(ctx: AuthCtx) { const identity = await getIdentity(ctx); return identity?.role ?? null; } export async function requireAdmin(ctx: AuthCtx) { const role = await getRole(ctx); if (role !== "admin") { throw new Error("Unauthorized"); } } export async function requireContributorOrAdmin(ctx: AuthCtx) { const role = await getRole(ctx); if (role !== "contributor" && role !== "admin") { throw new Error("Unauthorized"); } } export async function requireSessionLegacyUserId(ctx: AuthCtx) { const identity = await getIdentity(ctx); const rawUserId = identity?.userId; const legacyUserId = typeof rawUserId === "number" ? rawUserId : Number.parseInt(String(rawUserId ?? ""), 10); if ( !Number.isFinite(legacyUserId) || legacyUserId <= 0 || !Number.isInteger(legacyUserId) ) { throw new Error("Unauthorized"); } return legacyUserId; } export async function getSessionLegacyUserId(ctx: AuthCtx) { const identity = await getIdentity(ctx); const rawUserId = identity?.userId; const legacyUserId = typeof rawUserId === "number" ? rawUserId : Number.parseInt(String(rawUserId ?? ""), 10); return Number.isFinite(legacyUserId) ? legacyUserId : null; } ================================================ FILE: convex/lib/courseContributors.ts ================================================ import { v } from "convex/values"; import { components } from "../_generated/api"; import type { Id } from "../_generated/dataModel"; import type { MutationCtx, QueryCtx } from "../_generated/server"; type ContributorCtx = QueryCtx | MutationCtx; type AdapterWhere = Array<{ field: string; operator?: "eq" | "in"; value: string | Array; }>; type AuthUserRow = { _id?: string | null; userId?: string | null; name?: string | null; image?: string | null; }; type AuthAccountRow = { userId?: string | null; providerId?: string | null; }; type PaginatedAdapterResponse = { page: T[]; isDone?: boolean; continueCursor?: string | null; }; export const courseContributorValidator = v.object({ legacyUserId: v.number(), name: v.string(), image: v.union(v.string(), v.null()), discordLinked: v.boolean(), }); export type CourseContributor = { legacyUserId: number; name: string; image: string | null; discordLinked: boolean; latestDate: number; active: boolean; }; async function findManyAll( ctx: ContributorCtx, model: "user" | "account", where: AdapterWhere, ): Promise { let cursor: string | null = null; const rows: T[] = []; while (true) { const page = (await ctx.runQuery(components.betterAuth.adapter.findMany, { model, where, paginationOpts: { cursor, numItems: 200 }, })) as PaginatedAdapterResponse; rows.push(...page.page); if (page.isDone) break; cursor = page.continueCursor ?? null; if (!cursor) break; } return rows; } async function getUsersByLegacyId( ctx: ContributorCtx, legacyUserIds: number[], ): Promise< Map > { const uniqueLegacyIds = Array.from( new Set( legacyUserIds.filter((legacyUserId) => Number.isFinite(legacyUserId)), ), ); if (!uniqueLegacyIds.length) { return new Map< number, { name: string; image: string | null; discordLinked: boolean } >(); } const users = await findManyAll(ctx, "user", [ { field: "userId", operator: "in", value: uniqueLegacyIds.map((legacyUserId) => String(legacyUserId)), }, ]); const authDocIds = users .map((user) => user._id) .filter((authDocId): authDocId is string => Boolean(authDocId)); const accounts = authDocIds.length === 0 ? [] : await findManyAll(ctx, "account", [ { field: "providerId", operator: "eq", value: "discord" }, { field: "userId", operator: "in", value: authDocIds }, ]); const discordLinkedAuthDocIds = new Set( accounts .map((account) => account.userId) .filter((authDocId): authDocId is string => Boolean(authDocId)), ); const map = new Map< number, { name: string; image: string | null; discordLinked: boolean } >(); for (const user of users) { const legacyUserId = Number.parseInt(user.userId ?? "", 10); if (!Number.isFinite(legacyUserId)) continue; map.set(legacyUserId, { name: user.name?.trim() || `User ${legacyUserId}`, image: typeof user.image === "string" && user.image.length > 0 ? user.image : null, discordLinked: typeof user._id === "string" && discordLinkedAuthDocIds.has(user._id), }); } return map; } export async function getRankedCourseContributors( ctx: ContributorCtx, courseId: Id<"courses">, ): Promise { const courseStories = await ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", courseId)) .collect(); const latestApprovalByUser = new Map(); for (const story of courseStories) { const approvals = await ctx.db .query("story_approval") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .collect(); for (const approval of approvals) { if (typeof approval.legacyUserId !== "number") continue; const existing = latestApprovalByUser.get(approval.legacyUserId) ?? 0; if (approval.date > existing) { latestApprovalByUser.set(approval.legacyUserId, approval.date); } } } const usersByLegacyId = await getUsersByLegacyId( ctx, Array.from(latestApprovalByUser.keys()), ); const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000; return Array.from(latestApprovalByUser.entries()) .map(([legacyUserId, latestDate]) => { const user = usersByLegacyId.get(legacyUserId); return { legacyUserId, name: user?.name ?? `User ${legacyUserId}`, image: user?.image ?? null, discordLinked: user?.discordLinked ?? false, latestDate, active: latestDate > cutoffMs, }; }) .sort((a, b) => b.latestDate - a.latestDate); } export function partitionCourseContributors(contributors: CourseContributor[]) { return { contributors: contributors .filter((contributor) => contributor.active) .map(({ legacyUserId, name, image, discordLinked }) => ({ legacyUserId, name, image, discordLinked, })), contributors_past: contributors .filter((contributor) => !contributor.active) .map(({ legacyUserId, name, image, discordLinked }) => ({ legacyUserId, name, image, discordLinked, })), }; } ================================================ FILE: convex/lib/courseCounts.ts ================================================ import type { Id } from "../_generated/dataModel"; import type { MutationCtx } from "../_generated/server"; export async function recomputeCoursePublishedCount( ctx: MutationCtx, courseId: Id<"courses">, ) { const course = await ctx.db.get(courseId); if (!course) { throw new Error(`Course ${courseId} not found`); } const stories = await ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", courseId)) .collect(); const count = stories.filter( (story) => story.public && !story.deleted, ).length; if (course.count !== count) { await ctx.db.patch(courseId, { count }); } return count; } ================================================ FILE: convex/lib/discordAvatarSync.ts ================================================ type DiscordAccountRecord = { accountId?: string | null; accessToken?: string | null; refreshToken?: string | null; accessTokenExpiresAt?: number | Date | null; providerId?: string | null; scope?: string | null; userId?: string | null; }; type DiscordTokenResponse = { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; }; type DiscordUserResponse = { id?: string; avatar?: string | null; discriminator?: string | null; }; export type DiscordAvatarSyncResult = | { ok: true; imageUrl: string | null; accessToken: string | null; refreshToken: string | null; accessTokenExpiresAt: number | null; scope: string | null; } | { ok: false; reason: string; }; function getDiscordCredentials() { const clientId = process.env.DISCORD_CLIENT_ID ?? process.env.AUTH_DISCORD_CLIENT_ID ?? null; const clientSecret = process.env.DISCORD_CLIENT_SECRET ?? process.env.AUTH_DISCORD_CLIENT_SECRET ?? null; if (!clientId || !clientSecret) { return null; } return { clientId, clientSecret }; } function getDiscordBotToken() { return ( process.env.DISCORD_TOKEN ?? process.env.DISCORD_BOT_TOKEN ?? process.env.BOT_TOKEN ?? null ); } async function fetchDiscordUserWithOAuth( accessToken: string, ): Promise { const response = await fetch("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (response.status === 401) { return null; } if (!response.ok) { const text = await response.text(); throw new Error(`Discord user fetch failed: ${response.status} ${text}`); } return (await response.json()) as DiscordUserResponse; } async function fetchDiscordUserById( discordAccountId: string, botToken: string, ): Promise { const response = await fetch( `https://discord.com/api/v10/users/${discordAccountId}`, { headers: { Authorization: `Bot ${botToken}`, }, }, ); if (response.status === 404) { return null; } if (!response.ok) { const text = await response.text(); throw new Error( `Discord bot user fetch failed: ${response.status} ${text}`, ); } return (await response.json()) as DiscordUserResponse; } async function refreshDiscordAccessToken(refreshToken: string) { const credentials = getDiscordCredentials(); if (!credentials) { throw new Error("Discord OAuth credentials are not configured"); } const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: credentials.clientId, client_secret: credentials.clientSecret, }); const response = await fetch("https://discord.com/api/v10/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body, }); if (!response.ok) { const text = await response.text(); throw new Error(`Discord token refresh failed: ${response.status} ${text}`); } return (await response.json()) as DiscordTokenResponse; } function buildDiscordAvatarUrl(user: DiscordUserResponse) { if (!user.id) return null; if (user.avatar) { const ext = user.avatar.startsWith("a_") ? "gif" : "png"; return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`; } const parsedDiscriminator = typeof user.discriminator === "string" ? Number.parseInt(user.discriminator, 10) : Number.NaN; const defaultAvatarIndex = typeof user.discriminator === "string" && user.discriminator.length > 0 && user.discriminator !== "0" && Number.isFinite(parsedDiscriminator) ? parsedDiscriminator % 5 : Number((BigInt(user.id) >> 22n) % 6n); return `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`; } async function tryFetchDiscordUserWithBot(account: DiscordAccountRecord) { const botToken = getDiscordBotToken(); if (!botToken || !account.accountId) { return null; } return await fetchDiscordUserById(account.accountId, botToken); } export async function syncDiscordAvatarFromAccount( account: DiscordAccountRecord, ): Promise { if (account.providerId !== "discord") { return { ok: false, reason: "not_discord" }; } let accessToken = typeof account.accessToken === "string" && account.accessToken.length > 0 ? account.accessToken : null; let refreshToken = typeof account.refreshToken === "string" && account.refreshToken.length > 0 ? account.refreshToken : null; let accessTokenExpiresAt = typeof account.accessTokenExpiresAt === "number" ? account.accessTokenExpiresAt : account.accessTokenExpiresAt instanceof Date ? account.accessTokenExpiresAt.getTime() : null; let scope = typeof account.scope === "string" && account.scope.length > 0 ? account.scope : null; try { const botUser = await tryFetchDiscordUserWithBot(account); if (botUser) { return { ok: true, imageUrl: buildDiscordAvatarUrl(botUser), accessToken, refreshToken, accessTokenExpiresAt, scope, }; } } catch { // Fall back to the user's OAuth session if bot lookup is unavailable. } if (!accessToken && !refreshToken) { return { ok: false, reason: "missing_tokens" }; } let user = accessToken !== null ? await fetchDiscordUserWithOAuth(accessToken) : null; if (!user && refreshToken) { const refreshed = await refreshDiscordAccessToken(refreshToken); accessToken = typeof refreshed.access_token === "string" && refreshed.access_token.length ? refreshed.access_token : null; refreshToken = typeof refreshed.refresh_token === "string" && refreshed.refresh_token.length ? refreshed.refresh_token : refreshToken; accessTokenExpiresAt = typeof refreshed.expires_in === "number" ? Date.now() + refreshed.expires_in * 1000 : accessTokenExpiresAt; scope = typeof refreshed.scope === "string" && refreshed.scope.length > 0 ? refreshed.scope : scope; if (!accessToken) { return { ok: false, reason: "refresh_missing_access_token" }; } user = await fetchDiscordUserWithOAuth(accessToken); } if (!user) { return { ok: false, reason: "unable_to_fetch_profile" }; } return { ok: true, imageUrl: buildDiscordAvatarUrl(user), accessToken, refreshToken, accessTokenExpiresAt, scope, }; } ================================================ FILE: convex/lib/phpbb.ts ================================================ import md5Raw from "js-md5"; const encoder = new TextEncoder(); function md5Hex(content: string): string { return bytesToHex(md5Bytes(encoder.encode(content))); } function md5Bytes(input: Uint8Array): Uint8Array { const md5 = md5Raw as unknown as { array: (data: Uint8Array | string) => number[]; }; const bytes = md5.array(input); return Uint8Array.from(bytes); } function bytesToHex(bytes: Uint8Array): string { let result = ""; for (const b of bytes) { result += b.toString(16).padStart(2, "0"); } return result; } function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; } const itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; export function phpbbHash(password: string): string { const count = 6; const randomBytes = new Uint8Array(count); globalThis.crypto.getRandomValues(randomBytes); const random = bytesToHex(randomBytes); return hashCryptPrivate(password, hashGensaltPrivate(random)); } function hashCryptPrivate(password: string, setting: string): string { let output = "*"; if (setting.substring(0, 3) !== "$H$" && setting.substring(0, 3) !== "$P$") { return output; } const countLog2 = itoa64.indexOf(setting[3]); if (countLog2 < 7 || countLog2 > 30) { return output; } let count = 1 << countLog2; const salt = setting.substring(4, 12); if (salt.length !== 8) { return output; } let hash = md5Bytes( concatBytes(encoder.encode(salt), encoder.encode(password)), ); do { hash = md5Bytes(concatBytes(hash, encoder.encode(password))); } while (--count); output = setting.substring(0, 12) + hashEncode64(hash, 16); return output; } function hashEncode64(input: Uint8Array | string, count: number): string { let output = ""; let i = 0; const getByte = (idx: number): number => { const val = input[idx]; return typeof val === "string" ? val.charCodeAt(0) : val; }; do { let value = getByte(i++); output += itoa64[value & 0x3f]; if (i < count) { value |= getByte(i) << 8; } output += itoa64[(value >> 6) & 0x3f]; if (i++ >= count) { break; } if (i < count) { value |= getByte(i) << 16; } output += itoa64[(value >> 12) & 0x3f]; if (i++ >= count) { break; } output += itoa64[(value >> 18) & 0x3f]; } while (i < count); return output; } function hashGensaltPrivate( input: string, iterationCountLog2: number = 6, ): string { let output = "$H$"; output += itoa64[Math.min(iterationCountLog2 + 5, 30)]; output += hashEncode64(input, 6); return output; } export function phpbbCheckHash(password: string, hash: string): boolean { if (hash.length === 34) { return hashCryptPrivate(password, hash) === hash; } return md5Hex(password) === hash; } ================================================ FILE: convex/lib/publicStoryContent.ts ================================================ import type { Id } from "../_generated/dataModel"; import type { MutationCtx, QueryCtx } from "../_generated/server"; type RecordValue = Record; function isRecord(value: unknown): value is RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value); } function sanitizeConvexValue(value: unknown): unknown { if (value === undefined) return undefined; if (Array.isArray(value)) { return value .map((item) => sanitizeConvexValue(item)) .filter((item) => item !== undefined); } if (!isRecord(value)) return value; const result: RecordValue = {}; for (const [key, item] of Object.entries(value)) { const next = sanitizeConvexValue(item); if (next !== undefined) result[key] = next; } return result; } function compactAudio(value: unknown): unknown { if (!isRecord(value)) return undefined; const audio: RecordValue = {}; if (typeof value.url === "string" && value.url.length > 0) { audio.url = value.url; } if (Array.isArray(value.keypoints) && value.keypoints.length > 0) { audio.keypoints = sanitizeConvexValue(value.keypoints); } return Object.keys(audio).length > 0 ? audio : undefined; } function compactStoryValue( value: unknown, path: readonly string[] = [], ): unknown { if (Array.isArray(value)) { return value .map((item, index) => compactStoryValue(item, [...path, String(index)])) .filter((item) => item !== undefined); } if (!isRecord(value)) return value; const isTopLevelElement = path.length === 2 && path[0] === "elements" && /^\d+$/.test(path[1] ?? ""); const result: RecordValue = {}; for (const [key, item] of Object.entries(value)) { if (key === "editor") continue; if (key === "audio") { if (isTopLevelElement) continue; const audio = compactAudio(item); if (audio !== undefined) result.audio = audio; continue; } if (key === "ssml") continue; const next = compactStoryValue(item, [...path, key]); if (next !== undefined) result[key] = next; } return result; } export function toPublicStoryJson(json: unknown): unknown { return sanitizeConvexValue(compactStoryValue(json)); } export async function upsertPublicStoryContent( ctx: MutationCtx, storyId: Id<"stories">, json: unknown, lastUpdated: number, ) { const publicJson = toPublicStoryJson(json); const existing = await ctx.db .query("story_public_content") .withIndex("by_story", (q) => q.eq("storyId", storyId)) .unique(); if (existing) { await ctx.db.patch(existing._id, { json: publicJson, lastUpdated, }); return existing._id; } return await ctx.db.insert("story_public_content", { storyId, json: publicJson, lastUpdated, }); } export async function getPublicStoryJson( ctx: QueryCtx, storyId: Id<"stories">, ) { const publicContent = await ctx.db .query("story_public_content") .withIndex("by_story", (q) => q.eq("storyId", storyId)) .unique(); if (publicContent) return publicContent.json; const legacyContent = await ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", storyId)) .unique(); return legacyContent?.json ?? null; } ================================================ FILE: convex/localization.ts ================================================ import { query } from "./_generated/server"; import { v } from "convex/values"; const localizationEntryValidator = v.object({ tag: v.string(), text: v.string(), }); export const getLocalizationWithEnglishFallback = query({ args: { languageId: v.id("languages"), }, returns: v.array(localizationEntryValidator), handler: async (ctx, args) => { const english = await ctx.db .query("languages") .withIndex("by_short", (q) => q.eq("short", "en")) .unique(); const englishRows = english ? await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", english._id), ) .collect() : []; const targetRows = english?._id === args.languageId ? englishRows : await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", args.languageId), ) .collect(); const merged = new Map(); for (const row of englishRows) { if (!row.tag || !row.text) continue; merged.set(row.tag, row.text); } for (const row of targetRows) { if (!row.tag || !row.text) continue; merged.set(row.tag, row.text); } return Array.from(merged.entries()).map(([tag, text]) => ({ tag, text })); }, }); export const getLocalizationByLegacyLanguageId = query({ args: { legacyLanguageId: v.number(), }, returns: v.array(localizationEntryValidator), handler: async (ctx, args) => { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyLanguageId)) .unique(); if (!language) return []; const rows = await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", language._id), ) .collect(); return rows .filter((row) => Boolean(row.tag) && Boolean(row.text)) .map((row) => ({ tag: row.tag, text: row.text })); }, }); const languageFlagValidator = v.object({ languageId: v.id("languages"), short: v.string(), flag: v.optional(v.number()), flag_file: v.optional(v.string()), }); export const getAllLanguageFlags = query({ args: {}, returns: v.array(languageFlagValidator), handler: async (ctx) => { const languages = await ctx.db.query("languages").collect(); return languages.map((language) => { const numericFlag = typeof language.flag === "number" ? language.flag : Number.isFinite(Number(language.flag)) ? Number(language.flag) : undefined; return { languageId: language._id, short: language.short, flag: numericFlag, flag_file: language.flag_file, }; }); }, }); export const getLanguageFlagByLegacyId = query({ args: { legacyLanguageId: v.number(), }, returns: v.union( v.object({ short: v.string(), flag: v.optional(v.union(v.number(), v.string())), flag_file: v.optional(v.string()), }), v.null(), ), handler: async (ctx, args) => { const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyLanguageId)) .unique(); if (!language) return null; const numericFlag = typeof language.flag === "number" ? language.flag : Number.isFinite(Number(language.flag)) ? Number(language.flag) : undefined; return { short: language.short, flag: numericFlag, flag_file: language.flag_file, }; }, }); ================================================ FILE: convex/localizationWrite.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireContributorOrAdmin } from "./lib/authorization"; export const setLocalization = mutation({ args: { legacyLanguageId: v.number(), tag: v.string(), text: v.string(), operationKey: v.optional(v.string()), }, returns: v.object({ id: v.union(v.number(), v.null()), language_id: v.number(), tag: v.string(), text: v.string(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyLanguageId)) .unique(); if (!language) { throw new Error(`Language ${args.legacyLanguageId} not found`); } const operationKey = args.operationKey ?? `localization:${args.legacyLanguageId}:${args.tag}:${Date.now()}`; const existing = await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", language._id).eq("tag", args.tag), ) .unique(); if (existing) { await ctx.db.patch(existing._id, { text: args.text, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } else { await ctx.db.insert("localizations", { languageId: language._id, tag: args.tag, text: args.text, mirrorUpdatedAt: Date.now(), lastOperationKey: operationKey, }); } return { id: existing?.legacyId ?? null, language_id: args.legacyLanguageId, tag: args.tag, text: args.text, }; }, }); ================================================ FILE: convex/lookupTables.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireContributorOrAdmin } from "./lib/authorization"; const languageValidator = { legacyId: v.number(), name: v.string(), short: v.string(), flag: v.optional(v.number()), flag_file: v.optional(v.string()), speaker: v.optional(v.string()), default_text: v.optional(v.string()), tts_replace: v.optional(v.string()), public: v.boolean(), rtl: v.boolean(), }; const imageValidator = { legacyId: v.string(), active: v.string(), gilded: v.string(), locked: v.string(), active_lip: v.string(), gilded_lip: v.string(), }; const avatarValidator = { legacyId: v.number(), link: v.string(), name: v.optional(v.string()), }; const speakerValidator = { legacyId: v.optional(v.number()), legacyLanguageId: v.number(), speaker: v.string(), gender: v.string(), type: v.string(), service: v.string(), }; const localizationValidator = { legacyId: v.optional(v.number()), legacyLanguageId: v.number(), tag: v.string(), text: v.string(), }; const courseValidator = { legacyId: v.number(), short: v.optional(v.string()), legacyLearningLanguageId: v.number(), legacyFromLanguageId: v.number(), public: v.boolean(), official: v.boolean(), name: v.optional(v.string()), about: v.optional(v.string()), conlang: v.optional(v.boolean()), tags: v.optional(v.array(v.string())), count: v.optional(v.number()), // Legacy denormalized fields kept only for Postgres-compat migration. // TODO(postgres-sunset): drop from mirror payload and schema. learning_language_name: v.optional(v.string()), from_language_name: v.optional(v.string()), contributors: v.optional(v.array(v.string())), contributors_past: v.optional(v.array(v.string())), todo_count: v.optional(v.number()), }; const avatarMappingValidator = { legacyId: v.optional(v.number()), legacyAvatarId: v.number(), legacyLanguageId: v.number(), name: v.optional(v.string()), speaker: v.optional(v.string()), }; export const upsertLanguage = mutation({ args: { language: v.object(languageValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("languages"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const existing = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.language.legacyId)) .unique(); const doc = { ...args.language, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("languages", doc); return { inserted: true, docId }; }, }); export const upsertImage = mutation({ args: { image: v.object(imageValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("images"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const existing = await ctx.db .query("images") .withIndex("by_id_value", (q) => q.eq("legacyId", args.image.legacyId)) .unique(); const doc = { ...args.image, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("images", doc); return { inserted: true, docId }; }, }); export const upsertAvatar = mutation({ args: { avatar: v.object(avatarValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("avatars"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const existing = await ctx.db .query("avatars") .withIndex("by_id_value", (q) => q.eq("legacyId", args.avatar.legacyId)) .unique(); const doc = { ...args.avatar, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("avatars", doc); return { inserted: true, docId }; }, }); export const upsertSpeaker = mutation({ args: { speaker: v.object(speakerValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("speakers"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.speaker.legacyLanguageId), ) .unique(); if (!language) { throw new Error( `Missing language for legacy id ${args.speaker.legacyLanguageId}`, ); } const existing = await ctx.db .query("speakers") .withIndex("by_speaker", (q) => q.eq("speaker", args.speaker.speaker)) .unique(); const doc = { legacyId: args.speaker.legacyId, languageId: language._id, speaker: args.speaker.speaker, gender: args.speaker.gender, type: args.speaker.type, service: args.speaker.service, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("speakers", doc); return { inserted: true, docId }; }, }); export const upsertLocalization = mutation({ args: { localization: v.object(localizationValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("localizations"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.localization.legacyLanguageId), ) .unique(); if (!language) { throw new Error( `Missing language for legacy id ${args.localization.legacyLanguageId}`, ); } const existing = await ctx.db .query("localizations") .withIndex("by_language_id_and_tag", (q) => q.eq("languageId", language._id).eq("tag", args.localization.tag), ) .unique(); const doc = { legacyId: args.localization.legacyId, languageId: language._id, tag: args.localization.tag, text: args.localization.text, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("localizations", doc); return { inserted: true, docId }; }, }); export const upsertCourse = mutation({ args: { course: v.object(courseValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("courses"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const learningLanguage = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.course.legacyLearningLanguageId), ) .unique(); if (!learningLanguage) { throw new Error( `Missing learning language for legacy id ${args.course.legacyLearningLanguageId}`, ); } const fromLanguage = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.course.legacyFromLanguageId), ) .unique(); if (!fromLanguage) { throw new Error( `Missing from language for legacy id ${args.course.legacyFromLanguageId}`, ); } const existing = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.course.legacyId)) .unique(); const doc = { legacyId: args.course.legacyId, short: args.course.short, learningLanguageId: learningLanguage._id, fromLanguageId: fromLanguage._id, public: args.course.public, official: args.course.official, name: args.course.name, about: args.course.about, conlang: args.course.conlang, tags: args.course.tags, count: args.course.count, learning_language_name: args.course.learning_language_name, from_language_name: args.course.from_language_name, contributors: args.course.contributors, contributors_past: args.course.contributors_past, todo_count: args.course.todo_count, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("courses", doc); return { inserted: true, docId }; }, }); export const upsertAvatarMapping = mutation({ args: { avatarMapping: v.object(avatarMappingValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("avatar_mappings"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const avatar = await ctx.db .query("avatars") .withIndex("by_id_value", (q) => q.eq("legacyId", args.avatarMapping.legacyAvatarId), ) .unique(); if (!avatar) { throw new Error( `Missing avatar for legacy id ${args.avatarMapping.legacyAvatarId}`, ); } const language = await ctx.db .query("languages") .withIndex("by_id_value", (q) => q.eq("legacyId", args.avatarMapping.legacyLanguageId), ) .unique(); if (!language) { throw new Error( `Missing language for legacy id ${args.avatarMapping.legacyLanguageId}`, ); } const existing = await ctx.db .query("avatar_mappings") .withIndex("by_avatar_id_and_language_id", (q) => q.eq("avatarId", avatar._id).eq("languageId", language._id), ) .unique(); const doc = { legacyId: args.avatarMapping.legacyId, avatarId: avatar._id, languageId: language._id, name: args.avatarMapping.name, speaker: args.avatarMapping.speaker, mirrorUpdatedAt: Date.now(), lastOperationKey: args.operationKey, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("avatar_mappings", doc); return { inserted: true, docId }; }, }); ================================================ FILE: convex/roles.ts ================================================ import { action } from "./_generated/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; export const setBetterAuthRolesBatch = action({ args: { users: v.array( v.object({ email: v.string(), role: v.string(), }), ), }, handler: async (ctx, args) => { let updated = 0; let skippedMissing = 0; let skippedSame = 0; const errors: Array<{ email: string; message: string }> = []; for (const user of args.users) { const email = user.email.toLowerCase(); try { const authUser = await ctx.runQuery( components.betterAuth.adapter.findOne, { model: "user", where: [{ field: "email", value: email }], }, ); if (!authUser?._id) { skippedMissing += 1; continue; } const currentRole = Array.isArray(authUser.role) ? authUser.role[0] : authUser.role; if (currentRole === user.role) { skippedSame += 1; continue; } await ctx.runMutation(components.betterAuth.adapter.updateOne, { input: { model: "user", where: [{ field: "_id", value: authUser._id }], update: { role: user.role }, }, }); updated += 1; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); errors.push({ email, message, }); } } return { updated, skippedMissing, skippedSame, errors }; }, }); ================================================ FILE: convex/schema.ts ================================================ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; const courseContributorDetailsValidator = v.object({ legacyUserId: v.number(), name: v.string(), image: v.union(v.string(), v.null()), discordLinked: v.boolean(), }); export default defineSchema({ languages: defineTable({ legacyId: v.number(), name: v.string(), short: v.string(), flag: v.optional(v.union(v.number(), v.string())), flag_file: v.optional(v.string()), speaker: v.optional(v.string()), default_text: v.optional(v.string()), tts_replace: v.optional(v.string()), public: v.boolean(), rtl: v.boolean(), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), // Backward compatibility for previously mirrored docs. mirror_updated_at: v.optional(v.number()), last_operation_key: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_short", ["short"]) .index("by_last_operation_key", ["lastOperationKey"]), images: defineTable({ legacyId: v.string(), active: v.string(), gilded: v.string(), locked: v.string(), active_lip: v.string(), gilded_lip: v.string(), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), // Backward compatibility for previously mirrored docs. mirror_updated_at: v.optional(v.number()), last_operation_key: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_last_operation_key", ["lastOperationKey"]), avatars: defineTable({ legacyId: v.number(), link: v.string(), name: v.optional(v.string()), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), // Backward compatibility for previously mirrored docs. mirror_updated_at: v.optional(v.number()), last_operation_key: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_last_operation_key", ["lastOperationKey"]), speakers: defineTable({ legacyId: v.optional(v.number()), languageId: v.id("languages"), speaker: v.string(), gender: v.string(), type: v.string(), service: v.string(), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_speaker", ["speaker"]) .index("by_language_id", ["languageId"]), localizations: defineTable({ legacyId: v.optional(v.number()), languageId: v.id("languages"), tag: v.string(), text: v.string(), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_language_id_and_tag", ["languageId", "tag"]), courses: defineTable({ legacyId: v.number(), short: v.optional(v.string()), learningLanguageId: v.id("languages"), fromLanguageId: v.id("languages"), public: v.boolean(), official: v.boolean(), name: v.optional(v.string()), about: v.optional(v.string()), conlang: v.optional(v.boolean()), tags: v.optional(v.array(v.string())), count: v.optional(v.number()), // Legacy denormalized fields kept only for Postgres-compat migration. // TODO(postgres-sunset): remove these once all readers use joined language docs. learning_language_name: v.optional(v.string()), from_language_name: v.optional(v.string()), contributors: v.optional(v.array(v.string())), contributors_past: v.optional(v.array(v.string())), contributorDetails: v.optional(v.array(courseContributorDetailsValidator)), contributorDetailsPast: v.optional( v.array(courseContributorDetailsValidator), ), todo_count: v.optional(v.number()), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_short", ["short"]) .index("by_public", ["public"]), avatar_mappings: defineTable({ legacyId: v.optional(v.number()), avatarId: v.id("avatars"), languageId: v.id("languages"), name: v.optional(v.string()), speaker: v.optional(v.string()), mirrorUpdatedAt: v.optional(v.number()), lastOperationKey: v.optional(v.string()), }) .index("by_id_value", ["legacyId"]) .index("by_language_id", ["languageId"]) .index("by_avatar_id_and_language_id", ["avatarId", "languageId"]), stories: defineTable({ duo_id: v.optional(v.string()), name: v.string(), set_id: v.optional(v.number()), set_index: v.optional(v.number()), // Temporary migration compatibility: // some existing Convex rows stored auth component user IDs (string), // while mirrored Postgres rows use legacy numeric user IDs. // TODO(post-migration): normalize to a single author identity type. authorId: v.optional(v.union(v.number(), v.string())), authorChangeId: v.optional(v.union(v.number(), v.string())), date: v.optional(v.number()), change_date: v.optional(v.number()), date_published: v.optional(v.number()), public: v.boolean(), imageId: v.optional(v.id("images")), courseId: v.id("courses"), status: v.union( v.literal("draft"), v.literal("feedback"), v.literal("finished"), ), approvalCount: v.optional(v.number()), deleted: v.boolean(), todo_count: v.number(), legacyId: v.optional(v.number()), }) .index("by_course", ["courseId"]) .index("by_duo_id_course", ["duo_id", "courseId"]) .index("by_status", ["status"]) .index("by_public", ["public", "deleted"]) .index("by_set", ["courseId", "set_id", "set_index"]) .index("by_course_public_deleted_set", [ "courseId", "public", "deleted", "set_id", "set_index", ]) .index("by_legacy_id", ["legacyId"]), story_content: defineTable({ storyId: v.id("stories"), text: v.string(), json: v.any(), lastUpdated: v.number(), }) .index("by_story", ["storyId"]) .index("by_updated", ["lastUpdated"]), story_public_content: defineTable({ storyId: v.id("stories"), json: v.any(), lastUpdated: v.number(), }) .index("by_story", ["storyId"]) .index("by_updated", ["lastUpdated"]), story_done: defineTable({ storyId: v.id("stories"), legacyUserId: v.optional(v.number()), time: v.number(), }) .index("by_story", ["storyId"]) .index("by_user", ["legacyUserId"]) .index("by_user_and_story", ["legacyUserId", "storyId"]) .index("by_user_time", ["legacyUserId", "time"]), story_done_state: defineTable({ storyId: v.id("stories"), courseId: v.id("courses"), legacyStoryId: v.number(), legacyCourseId: v.number(), legacyUserId: v.number(), lastDoneAt: v.number(), }) .index("by_user_and_story", ["legacyUserId", "storyId"]) .index("by_user_and_course", ["legacyUserId", "courseId"]) .index("by_user_and_last_done_at", ["legacyUserId", "lastDoneAt"]), course_activity: defineTable({ courseId: v.id("courses"), legacyCourseId: v.number(), legacyUserId: v.number(), lastDoneAt: v.number(), }) .index("by_user_and_course", ["legacyUserId", "courseId"]) .index("by_user_and_last_done_at", ["legacyUserId", "lastDoneAt"]), user_preferences: defineTable({ tokenIdentifier: v.string(), legacyUserId: v.optional(v.number()), hideStoryQuestions: v.boolean(), updatedAt: v.number(), }).index("by_token_identifier", ["tokenIdentifier"]), story_approval: defineTable({ storyId: v.id("stories"), legacyUserId: v.optional(v.number()), date: v.number(), legacyId: v.optional(v.number()), }) .index("by_story", ["storyId"]) .index("by_date", ["date"]) .index("by_user", ["legacyUserId"]) .index("by_story_and_user", ["storyId", "legacyUserId"]) .index("by_legacy_id", ["legacyId"]), discord_stories_role_sync: defineTable({ legacyUserId: v.number(), discordAccountId: v.union(v.string(), v.null()), eligibleStoriesCount: v.union(v.number(), v.null()), assignedStoriesCount: v.union(v.number(), v.null()), syncStatus: v.union( v.literal("assigned"), v.literal("up_to_date"), v.literal("no_milestone"), v.literal("not_linked"), v.literal("member_not_found"), v.literal("error"), ), lastSyncedAt: v.number(), lastError: v.union(v.string(), v.null()), }) .index("by_legacy_user_id", ["legacyUserId"]) .index("by_sync_status", ["syncStatus"]), }); ================================================ FILE: convex/storyApproval.ts ================================================ import { mutation, query, type MutationCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { internal } from "./_generated/api"; import { requireContributorOrAdmin, requireSessionLegacyUserId, } from "./lib/authorization"; import { recomputeCoursePublishedCount } from "./lib/courseCounts"; import { getRankedCourseContributors, partitionCourseContributors, } from "./lib/courseContributors"; const storyApprovalInputValidator = { legacyStoryId: v.number(), date: v.optional(v.number()), legacyApprovalId: v.optional(v.number()), }; async function recomputeCourseContributors( ctx: MutationCtx, courseId: Id<"courses">, ): Promise<{ contributors: string[]; contributors_past: string[]; contributorDetails: Array<{ legacyUserId: number; name: string; image: string | null; discordLinked: boolean; }>; contributorDetailsPast: Array<{ legacyUserId: number; name: string; image: string | null; discordLinked: boolean; }>; }> { const ranked = await getRankedCourseContributors(ctx, courseId); const contributorLists = partitionCourseContributors(ranked); return { contributors: contributorLists.contributors.map((row) => row.name), contributors_past: contributorLists.contributors_past.map( (row) => row.name, ), contributorDetails: contributorLists.contributors, contributorDetailsPast: contributorLists.contributors_past, }; } export const upsertStoryApproval = mutation({ args: storyApprovalInputValidator, returns: v.object({ inserted: v.boolean(), docId: v.id("story_approval"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const legacyUserId = await requireSessionLegacyUserId(ctx); const identity = (await ctx.auth.getUserIdentity()) as { name?: string | null; } | null; const actorName = identity?.name?.trim() || `user_${legacyUserId}`; const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story) { throw new Error(`Missing story for legacy id ${args.legacyStoryId}`); } const existing = await ctx.db .query("story_approval") .withIndex("by_story_and_user", (q) => q.eq("storyId", story._id).eq("legacyUserId", legacyUserId), ) .unique(); const doc = { storyId: story._id, legacyUserId, date: args.date ?? Date.now(), legacyId: args.legacyApprovalId, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("story_approval", doc); return { inserted: true, docId }; }, }); export const deleteStoryApproval = mutation({ args: { legacyStoryId: v.number(), }, returns: v.object({ deleted: v.boolean(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const legacyUserId = await requireSessionLegacyUserId(ctx); const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story) { return { deleted: false }; } const existing = await ctx.db .query("story_approval") .withIndex("by_story_and_user", (q) => q.eq("storyId", story._id).eq("legacyUserId", legacyUserId), ) .unique(); if (!existing) return { deleted: false }; await ctx.db.delete(existing._id); return { deleted: true }; }, }); export const deleteStoryApprovalByLegacyId = mutation({ args: { legacyApprovalId: v.number(), }, returns: v.object({ deleted: v.boolean(), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const existing = await ctx.db .query("story_approval") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyApprovalId)) .unique(); if (!existing) return { deleted: false }; await ctx.db.delete(existing._id); return { deleted: true }; }, }); export const toggleStoryApproval = mutation({ args: { legacyStoryId: v.number(), operationKey: v.optional(v.string()), }, returns: v.object({ count: v.number(), story_status: v.union( v.literal("draft"), v.literal("feedback"), v.literal("finished"), ), finished_in_set: v.number(), action: v.union(v.literal("added"), v.literal("deleted")), published: v.array(v.number()), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const legacyUserId = await requireSessionLegacyUserId(ctx); const identity = (await ctx.auth.getUserIdentity()) as { name?: string | null; } | null; const actorName = identity?.name?.trim() || `user_${legacyUserId}`; const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story || typeof story.legacyId !== "number") { throw new Error(`Story ${args.legacyStoryId} not found`); } const existing = await ctx.db .query("story_approval") .withIndex("by_story_and_user", (q) => q.eq("storyId", story._id).eq("legacyUserId", legacyUserId), ) .unique(); let action: "added" | "deleted"; if (existing) { await ctx.db.delete(existing._id); action = "deleted"; } else { await ctx.db.insert("story_approval", { storyId: story._id, legacyUserId, date: Date.now(), }); action = "added"; } const approvals = await ctx.db .query("story_approval") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .collect(); const count = approvals.length; const story_status: "draft" | "feedback" | "finished" = count === 0 ? "draft" : count === 1 ? "feedback" : "finished"; await ctx.db.patch(story._id, { status: story_status, approvalCount: count, }); const storiesInCourse = await ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", story.courseId)) .collect(); const finishedStoriesInSet = storiesInCourse.filter( (row) => row.set_id === story.set_id && row.status === "finished" && !row.deleted, ); const finished_in_set = finishedStoriesInSet.length; const published: number[] = []; let datePublishedMs: number | null = null; if (finished_in_set >= 4) { datePublishedMs = Date.now(); for (const row of finishedStoriesInSet) { if (row.public) continue; await ctx.db.patch(row._id, { public: true, date_published: datePublishedMs, }); if (typeof row.legacyId === "number") { published.push(row.legacyId); } } } let courseCount: number | null = null; if (published.length > 0) { courseCount = await recomputeCoursePublishedCount(ctx, story.courseId); } const { contributors, contributors_past, contributorDetails, contributorDetailsPast, } = await recomputeCourseContributors(ctx, story.courseId); await ctx.db.patch(story.courseId, { contributors, contributors_past, contributorDetails, contributorDetailsPast, }); const operationKey = args.operationKey ?? `story_approval:${story.legacyId}:user:${legacyUserId}:toggle:${Date.now()}`; await ctx.scheduler.runAfter( 0, internal.editorSideEffects.onStoryApprovalToggled, { operationKey, storyId: story.legacyId, action, count, storyStatus: story_status, finishedInSet: finished_in_set, publishedCount: published.length, actorName, actorLegacyUserId: legacyUserId, }, ); return { count, story_status, finished_in_set, action, published, }; }, }); export const getApprovalCountByStory = query({ args: { legacyStoryId: v.number(), }, returns: v.number(), handler: async (ctx, args) => { const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story) return 0; const approvals = await ctx.db .query("story_approval") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .collect(); return approvals.length; }, }); ================================================ FILE: convex/storyDone.ts ================================================ import { mutation, query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { v } from "convex/values"; import { getSessionLegacyUserId } from "./lib/authorization"; const storyDoneInputValidator = v.object({ legacyStoryId: v.number(), time: v.optional(v.number()), }); const dashboardCourseValidator = v.object({ short: v.string(), name: v.string(), learningLanguageName: v.string(), learningLanguageShort: v.string(), learningLanguageFlag: v.optional(v.union(v.number(), v.string())), learningLanguageFlagFile: v.optional(v.string()), }); const nextStepValidator = v.object({ course: dashboardCourseValidator, completedCount: v.number(), totalCount: v.number(), nextStoryId: v.union(v.number(), v.null()), reviewStoryId: v.union(v.number(), v.null()), }); export const recordStoryDone = mutation({ args: storyDoneInputValidator.fields, returns: v.object({ inserted: v.boolean(), docId: v.id("story_done"), }), handler: async (ctx, args) => { const legacyUserId = await getSessionLegacyUserId(ctx); const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story) { throw new Error(`Missing story for legacy id ${args.legacyStoryId}`); } const doneAt = args.time ?? Date.now(); const docId = await ctx.db.insert("story_done", { storyId: story._id, legacyUserId: legacyUserId ?? undefined, time: doneAt, }); if (typeof legacyUserId === "number") { const course = await ctx.db.get(story.courseId); if ( course && typeof course.legacyId === "number" && typeof story.legacyId === "number" ) { await upsertStoryDoneState(ctx, { legacyUserId, storyId: story._id, courseId: story.courseId, legacyStoryId: story.legacyId, legacyCourseId: course.legacyId, lastDoneAt: doneAt, }); await upsertCourseActivity(ctx, { legacyUserId, courseId: story.courseId, legacyCourseId: course.legacyId, lastDoneAt: doneAt, }); } } return { inserted: true, docId }; }, }); export const getDoneStoryIdsForCourse = query({ args: { legacyCourseId: v.number(), legacyUserId: v.number(), }, returns: v.array(v.number()), handler: async (ctx, args) => { const course = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyCourseId)) .unique(); if (!course) return []; return await getDoneStoryIdsForCourseIdAndUser( ctx, course._id, args.legacyUserId, ); }, }); export const getDoneStoryIdsForCurrentUserInCourse = query({ args: { courseShort: v.string(), }, returns: v.array(v.number()), handler: async (ctx, args) => { const legacyUserId = await getSessionLegacyUserId(ctx); if (!legacyUserId) return []; const course = await ctx.db .query("courses") .withIndex("by_short", (q) => q.eq("short", args.courseShort)) .unique(); if (!course) return []; return await getDoneStoryIdsForCourseIdAndUser( ctx, course._id, legacyUserId, ); }, }); async function getDoneStoryIdsForCourseIdAndUser( ctx: QueryCtx, courseId: Id<"courses">, legacyUserId: number, ) { const doneStateRows = await ctx.db .query("story_done_state") .withIndex("by_user_and_course", (q) => q.eq("legacyUserId", legacyUserId).eq("courseId", courseId), ) .collect(); if (doneStateRows.length > 0) { const storyIds = new Set(); for (const row of doneStateRows) { storyIds.add(row.legacyStoryId); } return Array.from(storyIds.values()); } return []; } export const getDoneCourseIdsForUser = query({ args: {}, returns: v.union(v.array(v.number()), v.null()), handler: async (ctx) => { const legacyUserId = await getCurrentIdentityLegacyUserId(ctx); if (!legacyUserId) return null; const activityRows = await ctx.db .query("course_activity") .withIndex("by_user_and_last_done_at", (q) => q.eq("legacyUserId", legacyUserId), ) .order("desc") .collect(); const uniqueCourseIds = new Set(); const result: Array = []; for (const row of activityRows) { if (uniqueCourseIds.has(row.legacyCourseId)) continue; uniqueCourseIds.add(row.legacyCourseId); result.push(row.legacyCourseId); } return result; }, }); export const getLastDoneCourseShortForLegacyUser = query({ args: { legacyUserId: v.number(), }, returns: v.union(v.string(), v.null()), handler: async (ctx, args) => { const activityRows = await ctx.db .query("course_activity") .withIndex("by_user_and_last_done_at", (q) => q.eq("legacyUserId", args.legacyUserId), ) .order("desc") .take(20); for (const row of activityRows) { const course = await ctx.db.get(row.courseId); if (!course?.short) continue; return course.short; } return null; }, }); export const getNextStoryForCurrentUserInCourse = query({ args: { courseShort: v.string(), currentStoryId: v.optional(v.number()), }, returns: v.union(nextStepValidator, v.null()), handler: async (ctx, args) => { const legacyUserId = await getCurrentIdentityLegacyUserId(ctx); if (!legacyUserId) return null; const course = await ctx.db .query("courses") .withIndex("by_short", (q) => q.eq("short", args.courseShort)) .unique(); if (!course) return null; return await getNextStepForCourse(ctx, { courseId: course._id, legacyUserId, currentStoryId: args.currentStoryId, }); }, }); async function upsertStoryDoneState( ctx: MutationCtx, args: { storyId: Id<"stories">; courseId: Id<"courses">; legacyStoryId: number; legacyCourseId: number; legacyUserId: number; lastDoneAt: number; }, ) { const existingRows = await ctx.db .query("story_done_state") .withIndex("by_user_and_story", (q) => q.eq("legacyUserId", args.legacyUserId).eq("storyId", args.storyId), ) .collect(); if (existingRows.length === 0) { await ctx.db.insert("story_done_state", args); return; } for (const row of existingRows) { await ctx.db.patch(row._id, { courseId: args.courseId, legacyStoryId: args.legacyStoryId, legacyCourseId: args.legacyCourseId, lastDoneAt: Math.max(row.lastDoneAt, args.lastDoneAt), }); } } async function getNextStepForCourse( ctx: QueryCtx, args: { courseId: Id<"courses">; legacyUserId: number; currentStoryId?: number; }, ) { const course = await ctx.db.get(args.courseId); if (!course?.public || !course.short) return null; const learningLanguage = await ctx.db.get(course.learningLanguageId); if (!learningLanguage) return null; const publicStories = await ctx.db .query("stories") .withIndex("by_course_public_deleted_set", (q) => q.eq("courseId", args.courseId).eq("public", true).eq("deleted", false), ) .collect(); const orderedStories = publicStories .filter( (story): story is typeof story & { legacyId: number } => typeof story.legacyId === "number", ) .sort((a, b) => { const setCmp = (a.set_id ?? 0) - (b.set_id ?? 0); if (setCmp !== 0) return setCmp; return (a.set_index ?? 0) - (b.set_index ?? 0); }); if (orderedStories.length === 0) return null; const doneStoryIds = new Set( await getDoneStoryIdsForCourseIdAndUser( ctx, args.courseId, args.legacyUserId, ), ); if (typeof args.currentStoryId === "number") { doneStoryIds.add(args.currentStoryId); } const totalCount = orderedStories.length; const completedCount = orderedStories.reduce( (count, story) => count + (doneStoryIds.has(story.legacyId) ? 1 : 0), 0, ); const currentIndex = typeof args.currentStoryId === "number" ? orderedStories.findIndex( (story) => story.legacyId === args.currentStoryId, ) : -1; let nextStory = currentIndex >= 0 ? orderedStories .slice(currentIndex + 1) .find((story) => !doneStoryIds.has(story.legacyId)) : undefined; if (!nextStory) { nextStory = orderedStories.find( (story) => !doneStoryIds.has(story.legacyId), ); } return { course: { short: course.short, name: course.name && course.name.trim().length > 0 ? course.name : learningLanguage.name, learningLanguageName: learningLanguage.name, learningLanguageShort: learningLanguage.short, learningLanguageFlag: learningLanguage.flag, learningLanguageFlagFile: learningLanguage.flag_file, }, completedCount, totalCount, nextStoryId: nextStory?.legacyId ?? null, reviewStoryId: orderedStories[0]?.legacyId ?? null, }; } async function upsertCourseActivity( ctx: MutationCtx, args: { courseId: Id<"courses">; legacyCourseId: number; legacyUserId: number; lastDoneAt: number; }, ) { const existingRows = await ctx.db .query("course_activity") .withIndex("by_user_and_course", (q) => q.eq("legacyUserId", args.legacyUserId).eq("courseId", args.courseId), ) .collect(); if (existingRows.length === 0) { await ctx.db.insert("course_activity", args); return; } for (const row of existingRows) { await ctx.db.patch(row._id, { legacyCourseId: args.legacyCourseId, lastDoneAt: Math.max(row.lastDoneAt, args.lastDoneAt), }); } } async function getCurrentIdentityLegacyUserId( ctx: QueryCtx, ): Promise { const identity = (await ctx.auth.getUserIdentity()) as { userId?: string | number | null; } | null; const rawLegacyUserId = identity?.userId; if (typeof rawLegacyUserId === "number" && Number.isFinite(rawLegacyUserId)) { return rawLegacyUserId; } if ( typeof rawLegacyUserId === "string" && Number.isFinite(Number(rawLegacyUserId)) ) { return Number(rawLegacyUserId); } return null; } ================================================ FILE: convex/storyPublicContent.ts ================================================ import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import { mutation } from "./_generated/server"; import { requireAdmin } from "./lib/authorization"; import { upsertPublicStoryContent } from "./lib/publicStoryContent"; export const backfillBatch = mutation({ args: { paginationOpts: paginationOptsValidator, }, returns: v.object({ processed: v.number(), continueCursor: v.string(), isDone: v.boolean(), }), handler: async (ctx, args) => { await requireAdmin(ctx); const page = await ctx.db .query("story_content") .order("asc") .paginate(args.paginationOpts); for (const content of page.page) { await upsertPublicStoryContent( ctx, content.storyId, content.json, content.lastUpdated, ); } return { processed: page.page.length, continueCursor: page.continueCursor, isDone: page.isDone, }; }, }); ================================================ FILE: convex/storyRead.ts ================================================ import { query } from "./_generated/server"; import { v } from "convex/values"; import { getPublicStoryJson } from "./lib/publicStoryContent"; const storyReadResultValidator = v.union( v.object({ id: v.number(), set_id: v.number(), course_id: v.number(), from_language: v.string(), from_language_id: v.number(), from_language_long: v.string(), from_language_rtl: v.boolean(), from_language_name: v.string(), learning_language: v.string(), learning_language_long: v.string(), learning_language_rtl: v.boolean(), course_short: v.string(), elements: v.array(v.any()), illustrations: v.object({ gilded: v.string(), active: v.string(), locked: v.string(), }), }), v.null(), ); const storyMetaResultValidator = v.union( v.object({ from_language_name: v.string(), image: v.string(), from_language_long: v.string(), learning_language_long: v.string(), }), v.null(), ); const storyPreviewResultValidator = v.union( v.object({ id: v.number(), title: v.string(), active: v.string(), gilded: v.string(), }), v.null(), ); function nonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value : ""; } export const getStoryByLegacyId = query({ args: { storyId: v.number(), }, returns: storyReadResultValidator, handler: async (ctx, args) => { const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.storyId)) .unique(); if (!story || typeof story.legacyId !== "number") return null; if (story.deleted) return null; const storyJson = await getPublicStoryJson(ctx, story._id); if (!storyJson) return null; const course = await ctx.db.get(story.courseId); if (!course) return null; const [fromLanguage, learningLanguage, image] = await Promise.all([ ctx.db.get(course.fromLanguageId), ctx.db.get(course.learningLanguageId), story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null), ]); if (!fromLanguage || !learningLanguage) return null; let parsedJson = storyJson; if (typeof parsedJson === "string") { try { parsedJson = JSON.parse(parsedJson); } catch { return null; } } const elements = Array.isArray(parsedJson?.elements) ? parsedJson.elements : []; const illustrations = parsedJson?.illustrations ?? {}; const active = nonEmptyString(illustrations.active) || (image?.active ?? ""); const gilded = nonEmptyString(illustrations.gilded) || (image?.gilded ?? ""); const locked = nonEmptyString(illustrations.locked) || (image?.locked ?? ""); return { id: story.legacyId, set_id: story.set_id ?? 0, course_id: course.legacyId, from_language: fromLanguage.short, from_language_id: fromLanguage.legacyId, from_language_long: fromLanguage.name, from_language_rtl: fromLanguage.rtl, from_language_name: story.name, learning_language: learningLanguage.short, learning_language_long: learningLanguage.name, learning_language_rtl: learningLanguage.rtl, course_short: `${learningLanguage.short}-${fromLanguage.short}`, elements, illustrations: { gilded, active, locked, }, }; }, }); export const getStoryMetaByLegacyId = query({ args: { storyId: v.number(), }, returns: storyMetaResultValidator, handler: async (ctx, args) => { const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.storyId)) .unique(); if (!story || story.deleted) return null; const course = await ctx.db.get(story.courseId); if (!course) return null; const [fromLanguage, learningLanguage, image] = await Promise.all([ ctx.db.get(course.fromLanguageId), ctx.db.get(course.learningLanguageId), story.imageId ? ctx.db.get(story.imageId) : Promise.resolve(null), ]); if (!fromLanguage || !learningLanguage) return null; return { from_language_name: story.name, image: image?.legacyId ?? "", from_language_long: fromLanguage.name, learning_language_long: learningLanguage.name, }; }, }); export const getStoryPreviewByLegacyId = query({ args: { storyId: v.number(), }, returns: storyPreviewResultValidator, handler: async (ctx, args) => { const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.storyId)) .unique(); if (!story || story.deleted || typeof story.legacyId !== "number") { return null; } const storyJson = await getPublicStoryJson(ctx, story._id); const image = story.imageId ? await ctx.db.get(story.imageId) : null; let parsedJson = storyJson; if (typeof parsedJson === "string") { try { parsedJson = JSON.parse(parsedJson); } catch { parsedJson = null; } } const illustrations = parsedJson?.illustrations ?? {}; const active = nonEmptyString(illustrations.active) || (image?.active ?? ""); const gilded = nonEmptyString(illustrations.gilded) || (image?.gilded ?? active); return { id: story.legacyId, title: story.name, active, gilded, }; }, }); ================================================ FILE: convex/storyTables.ts ================================================ import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireContributorOrAdmin } from "./lib/authorization"; import { upsertPublicStoryContent } from "./lib/publicStoryContent"; const storyValidator = { legacyId: v.number(), duo_id: v.optional(v.string()), name: v.string(), set_id: v.optional(v.number()), set_index: v.optional(v.number()), // Temporary migration compatibility with pre-existing rows. // TODO(post-migration): tighten to one type after author identity normalization. authorId: v.optional(v.union(v.number(), v.string())), authorChangeId: v.optional(v.union(v.number(), v.string())), date: v.optional(v.number()), change_date: v.optional(v.number()), date_published: v.optional(v.number()), public: v.boolean(), legacyImageId: v.optional(v.string()), legacyCourseId: v.number(), status: v.union( v.literal("draft"), v.literal("feedback"), v.literal("finished"), ), approvalCount: v.optional(v.number()), deleted: v.boolean(), todo_count: v.number(), }; const storyContentValidator = { legacyStoryId: v.number(), text: v.string(), json: v.any(), lastUpdated: v.number(), }; export const upsertStory = mutation({ args: { story: v.object(storyValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("stories"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const course = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.story.legacyCourseId), ) .unique(); if (!course) { throw new Error( `Missing course for legacy id ${args.story.legacyCourseId}`, ); } const image = args.story.legacyImageId ? await ctx.db .query("images") .withIndex("by_id_value", (q) => q.eq("legacyId", args.story.legacyImageId!), ) .unique() : null; const existing = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.story.legacyId)) .unique(); const doc = { legacyId: args.story.legacyId, duo_id: args.story.duo_id, name: args.story.name, set_id: args.story.set_id, set_index: args.story.set_index, authorId: args.story.authorId, authorChangeId: args.story.authorChangeId, date: args.story.date, change_date: args.story.change_date, date_published: args.story.date_published, public: args.story.public, imageId: image?._id, courseId: course._id, status: args.story.status, approvalCount: args.story.approvalCount ?? existing?.approvalCount ?? 0, deleted: args.story.deleted, todo_count: args.story.todo_count, }; if (existing) { await ctx.db.replace(existing._id, doc); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("stories", doc); return { inserted: true, docId }; }, }); export const upsertStoryContent = mutation({ args: { storyContent: v.object(storyContentValidator), operationKey: v.optional(v.string()), }, returns: v.object({ inserted: v.boolean(), docId: v.id("story_content"), }), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.storyContent.legacyStoryId), ) .unique(); if (!story) { throw new Error( `Missing story for legacy id ${args.storyContent.legacyStoryId}`, ); } const existing = await ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .unique(); const doc = { storyId: story._id, text: args.storyContent.text, json: args.storyContent.json, lastUpdated: args.storyContent.lastUpdated, }; if (existing) { await ctx.db.replace(existing._id, doc); await upsertPublicStoryContent( ctx, story._id, args.storyContent.json, args.storyContent.lastUpdated, ); return { inserted: false, docId: existing._id }; } const docId = await ctx.db.insert("story_content", doc); await upsertPublicStoryContent( ctx, story._id, args.storyContent.json, args.storyContent.lastUpdated, ); return { inserted: true, docId }; }, }); ================================================ FILE: convex/storyWrite.ts ================================================ import { internal } from "./_generated/api"; import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { requireContributorOrAdmin, requireSessionLegacyUserId, } from "./lib/authorization"; import { recomputeCoursePublishedCount } from "./lib/courseCounts"; import { upsertPublicStoryContent } from "./lib/publicStoryContent"; export const setStory = mutation({ args: { legacyStoryId: v.optional(v.number()), duo_id: v.string(), name: v.string(), image: v.string(), set_id: v.number(), set_index: v.number(), legacyCourseId: v.number(), text: v.string(), json: v.any(), todo_count: v.number(), change_date: v.string(), confirmOfficialOverwrite: v.optional(v.boolean()), operationKey: v.optional(v.string()), }, returns: v.union( v.null(), v.object({ id: v.number(), name: v.string(), course_id: v.number(), text: v.string(), todo_count: v.number(), }), ), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const authorChangeLegacyUserId = await requireSessionLegacyUserId(ctx); const identity = (await ctx.auth.getUserIdentity()) as { name?: string | null; role?: string | null; } | null; const actorName = identity?.name?.trim() || `user_${authorChangeLegacyUserId}`; const course = await ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.legacyCourseId)) .unique(); if (!course) { throw new Error(`Course ${args.legacyCourseId} not found`); } if (course.official) { if (identity?.role !== "admin") { throw new Error("Official stories cannot be overwritten."); } if (!args.confirmOfficialOverwrite) { throw new Error( "Official story overwrite requires explicit confirmation.", ); } } const storyById = args.legacyStoryId !== undefined ? await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId!), ) .unique() : null; const storyByDuoId = !storyById && args.duo_id ? (( await ctx.db .query("stories") .withIndex("by_duo_id_course", (q) => q.eq("duo_id", args.duo_id).eq("courseId", course._id), ) .collect() )[0] ?? null) : null; const story = storyById ?? storyByDuoId; if (!story || story.legacyId === undefined) return null; const image = args.image ? await ctx.db .query("images") .withIndex("by_id_value", (q) => q.eq("legacyId", args.image)) .unique() : null; const changeDateMillis = Date.parse(args.change_date); const operationKey = args.operationKey ?? `story:${story.legacyId}:set_story:${Date.now()}`; const previousCourseId = story.courseId; const movedPublishedStory = previousCourseId !== course._id && story.public && !story.deleted; await ctx.db.patch(story._id, { duo_id: args.duo_id, name: args.name, imageId: image?._id, change_date: Number.isFinite(changeDateMillis) ? changeDateMillis : Date.now(), authorChangeId: authorChangeLegacyUserId, set_id: args.set_id, set_index: args.set_index, courseId: course._id, todo_count: args.todo_count, }); const existingContent = await ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .unique(); const lastUpdated = Date.now(); if (existingContent) { await ctx.db.patch(existingContent._id, { text: args.text, json: args.json, lastUpdated, }); } else { await ctx.db.insert("story_content", { storyId: story._id, text: args.text, json: args.json, lastUpdated, }); } await upsertPublicStoryContent(ctx, story._id, args.json, lastUpdated); const storiesInCourse = await ctx.db .query("stories") .withIndex("by_course", (q) => q.eq("courseId", course._id)) .collect(); const courseTodoCount = storiesInCourse.reduce( (acc, row) => acc + (row.todo_count ?? 0), 0, ); await ctx.db.patch(course._id, { todo_count: courseTodoCount }); if (movedPublishedStory) { await recomputeCoursePublishedCount(ctx, previousCourseId); await recomputeCoursePublishedCount(ctx, course._id); } await ctx.scheduler.runAfter(0, internal.editorSideEffects.onStorySaved, { operationKey, storyId: story.legacyId, storyName: args.name, courseId: args.legacyCourseId, text: args.text, todoCount: args.todo_count, actorName, actorLegacyUserId: authorChangeLegacyUserId, }); return { id: story.legacyId, name: args.name, course_id: args.legacyCourseId, text: args.text, todo_count: args.todo_count, }; }, }); export const deleteStory = mutation({ args: { legacyStoryId: v.number(), operationKey: v.optional(v.string()), }, returns: v.union( v.null(), v.object({ id: v.number(), name: v.string(), course_id: v.number(), text: v.string(), }), ), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const actorLegacyUserId = await requireSessionLegacyUserId(ctx); const identity = (await ctx.auth.getUserIdentity()) as { name?: string | null; } | null; const actorName = identity?.name?.trim() || `user_${actorLegacyUserId}`; const story = await ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.legacyStoryId)) .unique(); if (!story || story.legacyId === undefined) return null; const [course, content] = await Promise.all([ ctx.db.get(story.courseId), ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", story._id)) .unique(), ]); if (!course) { throw new Error(`Course missing for story ${args.legacyStoryId}`); } await ctx.db.patch(story._id, { deleted: true, public: false, }); if (story.public) { await recomputeCoursePublishedCount(ctx, story.courseId); } const operationKey = args.operationKey ?? `story:${story.legacyId}:delete:${Date.now()}`; await ctx.scheduler.runAfter(0, internal.editorSideEffects.onStoryDeleted, { operationKey, storyId: story.legacyId, storyName: story.name, courseId: course.legacyId, actorName, actorLegacyUserId, }); return { id: story.legacyId, name: story.name, course_id: course.legacyId, text: content?.text ?? "", }; }, }); export const importStory = mutation({ args: { sourceLegacyStoryId: v.number(), targetLegacyCourseId: v.number(), operationKey: v.optional(v.string()), }, returns: v.union( v.null(), v.object({ id: v.number(), course_id: v.number(), text: v.string(), name: v.string(), }), ), handler: async (ctx, args) => { await requireContributorOrAdmin(ctx); const authorLegacyUserId = await requireSessionLegacyUserId(ctx); const identity = (await ctx.auth.getUserIdentity()) as { name?: string | null; } | null; const actorName = identity?.name?.trim() || `user_${authorLegacyUserId}`; const [sourceStory, targetCourse] = await Promise.all([ ctx.db .query("stories") .withIndex("by_legacy_id", (q) => q.eq("legacyId", args.sourceLegacyStoryId), ) .unique(), ctx.db .query("courses") .withIndex("by_id_value", (q) => q.eq("legacyId", args.targetLegacyCourseId), ) .unique(), ]); if (!sourceStory || !targetCourse) return null; const sourceContent = await ctx.db .query("story_content") .withIndex("by_story", (q) => q.eq("storyId", sourceStory._id)) .unique(); if (!sourceContent) return null; const last = await ctx.db .query("stories") .withIndex("by_legacy_id") .order("desc") .take(1); const newLegacyId = Math.max(1, Number(last[0]?.legacyId ?? 0) + 1); const now = Date.now(); const newStoryId = await ctx.db.insert("stories", { legacyId: newLegacyId, duo_id: sourceStory.duo_id, name: sourceStory.name, set_id: sourceStory.set_id, set_index: sourceStory.set_index, authorId: authorLegacyUserId, authorChangeId: authorLegacyUserId, date: now, change_date: now, public: false, imageId: sourceStory.imageId, courseId: targetCourse._id, status: "draft", approvalCount: 0, deleted: false, todo_count: 0, }); const lastUpdated = Date.now(); await ctx.db.insert("story_content", { storyId: newStoryId, text: sourceContent.text, json: sourceContent.json, lastUpdated, }); await upsertPublicStoryContent( ctx, newStoryId, sourceContent.json, lastUpdated, ); const operationKey = args.operationKey ?? `story:${newLegacyId}:import:${args.targetLegacyCourseId}:${Date.now()}`; await ctx.scheduler.runAfter( 0, internal.editorSideEffects.onStoryImported, { operationKey, storyId: newLegacyId, storyName: sourceStory.name, courseId: args.targetLegacyCourseId, text: sourceContent.text, actorName, actorLegacyUserId: authorLegacyUserId, }, ); return { id: newLegacyId, course_id: args.targetLegacyCourseId, text: sourceContent.text, name: sourceStory.name, }; }, }); ================================================ FILE: convex/tsconfig.json ================================================ { "compilerOptions": { "allowJs": true, "strict": true, "moduleResolution": "Bundler", "jsx": "react-jsx", "skipLibCheck": true, "allowSyntheticDefaultImports": true, "target": "ESNext", "lib": ["ES2021", "dom"], "forceConsistentCasingInFileNames": true, "module": "ESNext", "isolatedModules": true, "noEmit": true }, "include": ["./**/*"], "exclude": ["./_generated"] } ================================================ FILE: convex/tsconfig.node.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"] }, "include": ["./editorSideEffects.ts"] } ================================================ FILE: convex/userPreferences.ts ================================================ import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { getSessionLegacyUserId } from "./lib/authorization"; const storyPreferencesValidator = v.object({ hasSavedPreference: v.boolean(), hideStoryQuestions: v.boolean(), }); export const getCurrentStoryPreferences = query({ args: {}, returns: storyPreferencesValidator, handler: async (ctx) => { const identity = (await ctx.auth.getUserIdentity()) as { tokenIdentifier?: string | null; } | null; const tokenIdentifier = identity?.tokenIdentifier; if (!tokenIdentifier) { return { hasSavedPreference: false, hideStoryQuestions: false, }; } const preference = await ctx.db .query("user_preferences") .withIndex("by_token_identifier", (q) => q.eq("tokenIdentifier", tokenIdentifier), ) .unique(); return { hasSavedPreference: preference !== null, hideStoryQuestions: preference?.hideStoryQuestions ?? false, }; }, }); export const setCurrentStoryPreferences = mutation({ args: { hideStoryQuestions: v.boolean(), }, returns: storyPreferencesValidator, handler: async (ctx, args) => { const identity = (await ctx.auth.getUserIdentity()) as { tokenIdentifier?: string | null; } | null; const tokenIdentifier = identity?.tokenIdentifier; if (!tokenIdentifier) { throw new Error("Unauthorized"); } const legacyUserId = await getSessionLegacyUserId(ctx); const existingPreference = await ctx.db .query("user_preferences") .withIndex("by_token_identifier", (q) => q.eq("tokenIdentifier", tokenIdentifier), ) .unique(); const updatedAt = Date.now(); if (existingPreference) { await ctx.db.patch(existingPreference._id, { legacyUserId: legacyUserId ?? undefined, hideStoryQuestions: args.hideStoryQuestions, updatedAt, }); } else { await ctx.db.insert("user_preferences", { tokenIdentifier, legacyUserId: legacyUserId ?? undefined, hideStoryQuestions: args.hideStoryQuestions, updatedAt, }); } return { hasSavedPreference: true, hideStoryQuestions: args.hideStoryQuestions, }; }, }); ================================================ FILE: database/stories/es-en-o/1_1_es-en-buenos-dias.txt ================================================ [DATA] fromLanguageName=Good Morning icon=783305780a6dad8e0e4eb34109d948e6a5fc2c35 set=1|1 [HEADER] > 早上好 ~ good~morning ^ zǎo~shang~hǎo $989/9a5413ae.mp3;3,50 [LINE] Speaker593: 早上好,|普丽提! ~ good~morning Priti ^ zǎo~shang~hǎo Pǔ~lì~tí $6252/510799dc.mp3;2,50;2,362;4,525 [LINE] Speaker560: 早上好,|亲爱的。 ~ good~morning darling ^ zǎo~shang~hǎo qīn~ài~de $6252/7a0ca48a.mp3;2,50;2,362;3,488;2,425 [LINE] Speaker560: 我的|钥匙|在哪里? ~ my keys (are)~where ^ wǒ~de yào~shi zài~nǎ~lǐ $6252/9827ee98.mp3;1,50;2,187;3,100;2,463;3,162 [LINE] Speaker593: 你的|钥匙? ~ your keys ^ nǐ~de yào~shi $989/53efd362.mp3;1,50;2,162;3,63 [MULTIPLE_CHOICE] > Priti can't find her keys. + Yes,that's right. - No,that's wrong. [LINE] Speaker560: 对,|我|需要|去上班。 ~ yes I need to~go~to~work ^ duì wǒ xū~yào qù~shàng~bān $6252/ec77e1c4.mp3;1,50;2,512;3,138;2,300;3,137 [ARRANGE] > Tap what you hear Speaker560: [(我|需要)​(我的|车)​(钥匙)!] ~ I need my car keys ^ wǒ~xū~yào wǒ~de chē yào~shi $6252/2d6bc2fe.mp3;1,50;3,162;2,300;2,113;2,100;3,200 [LINE] Speaker593: 普丽提!|你的|钥匙|在这儿,|在桌子上! ~  Priti your keys (are)~here on~the~table ^ Pǔ~lì~tí nǐ~de yào~shi zài~zhèr zài~zhuō~zi~shàng $6252/c6326dbc.mp3;3,50;2,1150;2,162;3,75;2,325;3,150;2,500;3,163;2,337 [POINT_TO_PHRASE] > Choose the option that means "tired." Speaker560: (对不起),​(亲爱的)。​(我​很)(+累)。​我​(工作)​(很忙)! ~ sorry darling I (am)~tired I (at)~work (am)~very~busy ^ duì~bu~qǐ qīn~ài~de wǒ~hěn lèi wǒ gōng~zuò hěn~máng $6252/251e4b84.mp3;3,50;3,725;2,462;2,713;2,150;2,200;2,800;3,162;2,338;2,175 [LINE] Speaker593: 你|想|喝|咖啡|吗? ~ you want to~drink coffee (question~marker) ^ nǐ xiǎng hē kā~fēi ma $6252/1db91224.mp3;1,50;2,150;2,187;3,125;2,363 [LINE] Speaker560: 好,|谢谢。 ~ yes thanks ^ hǎo xiè~xie $6252/239d1c4e.mp3;1,50;3,512 [LINE] Speaker593: 好了,|这儿|给|你,|亲爱的。 ~ OK this (is)~for you darling ^ hǎo~le zhèr gěi nǐ qīn~ài~de $6252/3f5aff8c.mp3;1,50;2,212;3,525;2,150;2,150;3,475;2,325 [LINE] Speaker560: 糖|~在哪里?|哦,|在这儿。 ~ sugar (is)~where oh (it's)~here ^ táng zài~nǎ~lǐ ó zài~zhèr $6252/f24875a2.mp3;1,50;2,237;3,138;2,975;2,237;3,175 [MULTIPLE_CHOICE] > Hmm... what is Priti doing? - pouring her coffee on the table - putting sugar on her keys + looking for some sugar for her coffee [LINE] > 普丽提|喝|她的|咖啡。 ~ Priti drinks her coffee ^ Pǔ~lì~tí hē tā~de kā~fēi $6252/80ea594a.mp3;3,50;2,987;2,225;2,150;3,100 [LINE] Speaker560: 呸! ~ yuck ^ pēi $6252/73a88754.mp3;1,50 [LINE] Speaker593: 怎么了? ~ what's~wrong ^ zěn~me~le $6252/7eff02b8.mp3;2,50;2,225 [LINE] Speaker560: 这|不是|糖,|是|盐! ~ this isn't sugar it's salt ^ zhè bú~shì táng shì yán $6252/b08af1e8.mp3;1,50;3,162;2,263;2,475;2,212 [LINE] Speaker593: 普丽提,|你|的确|是|累|坏了! ~ Priti you indeed are tired to~the~extreme ^ Pǔ~lì~tí nǐ dí~què shì lèi huài~le $6252/f09a9af4.mp3;3,50;2,900;3,137;2,363;3,112;2,425 [LINE] Speaker560: 对,|我|需要|一杯新的|咖啡,|加糖的,|而|不加盐! ~ yes I need a~new~cup~of coffee with~sugar and without~salt ^ duì wǒ xū~yào yì~bēi~xīn~de kā~fēi jiā~táng~de ér bù~jiā~yán $6252/3b467ce4.mp3;1,50;2,512;3,150;2,300;2,113;2,162;2,175;3,88;3,662;2,438;2,437;2,125;2,113;2,200 [MULTIPLE_CHOICE] > Priti was so tired that... - ...she fell asleep in the kitchen. + ...she put salt in her coffee. - ...she put her keys in her coffee. [MATCH] > Tap the pairs - 需要 <> need - 亲爱的 <> darling - 咖啡 <> coffee - 钥匙 <> keys - 糖 <> sugar ================================================ FILE: database/stories/es-en-o/1_2_es-en-una-cita.txt ================================================ [DATA] fromLanguageName=A Date icon=df24f7756b139f6eda927eb776621b9febe1a3f1 set=1|2 [HEADER] > Una cita ~ a date [LINE] > Bea está en una cita en un restaurante. ~ Bea is on a date in a restaurant [LINE] Speaker507: ¡Hola! ¿Cómo~estás? ~ hello how~are~you [LINE] Speaker100: ¡Bien! ~ fine [LINE] Speaker100: ¿Qué quieres comer? ~ what do~you~want to~eat [LINE] Speaker507: Una ensalada. ~ a salad [SELECT_PHRASE] > Select the missing phrase Speaker507: Hoy tengo [un~partido~importante]. ~ today I~have an~important~game + un partido importante - un batido importante - una parte imponente [LINE] Speaker100: Ah, ¿te~gustan los~deportes? ~ oh do~you~like (the)~sports [LINE] Speaker507: Sí, yo juego~al fútbol. ~ yes I play soccer [MULTIPLE_CHOICE] > Bea is ordering a salad because… + …she is playing soccer today. - …her date said the salad is delicious. - …she just ate a whole pizza. [LINE] Speaker100: ¡Yo~también! ~ me~too [LINE] Speaker507: ¡Súper! ~ great [LINE] Speaker507: ¿De~dónde~eres? ~ where~are~you~from [LINE] Speaker100: Soy americano. ~ I~am American [ARRANGE] > Tap what you hear Speaker100: [(Mi~padre) (es~de~Cuba) (y) (mi~madre) (es~de~México).] ~ my~father is~from~Cuba and my~mother is~from~Mexico [LINE] Speaker507: ¡Me~encanta Cuba! ~ I~love Cuba [LINE] Speaker100: Cuba es muy bonita. ~ Cuba is very beautiful [LINE] Speaker507: ¿Tienes mascotas? ~ do~you~have pets [LINE] Speaker100: Tengo dos gatos y un perro. ~ I~have two cats and a dog [POINT_TO_PHRASE] > Choose the option that means "also." Speaker507: Yo (+también) (tengo) un (perro), (José). ~ I also have a dog José [LINE] Speaker100: ¿Eh? Yo~me~llamo Daniel… ~ eh my~name~is Daniel [LINE] Speaker100: ¿Tú~no~eres Gabriela? ~ aren't~you Gabriela [LINE] Speaker507: ¡No! ~ no [LINE] Speaker507: ¡Yo~me~llamo Bea! ~ my~name~is Bea [MULTIPLE_CHOICE] > Did you catch that? Bea and Daniel… - …bought tickets to Cuba. + …were supposed to meet different people. - …are walking their dogs. [LINE] > Una mujer camina hacia Daniel. ~ a woman walks towards Daniel [LINE] Speaker101: ¡Hola! ¿Eres Daniel? Soy Gabriela. ~ hello are~you Daniel I~am Gabriela [LINE] Speaker100: No, lo~siento. Soy José. ~ no I'm~sorry I~am José [MULTIPLE_CHOICE] > Whoa! What just happened? - Bea changed her name to Gabriela. + Daniel lied to his real date because he likes Bea. - Bea and Daniel's dogs played soccer. [MATCH] > Tap the pairs - y <> and - Soy <> I am - ensalada <> salad - Me encanta <> I love - Eh <> eh ================================================ FILE: database/stories/es-en-o/1_3_es-en-una-cosa.txt ================================================ [DATA] fromLanguageName=One Thing icon=717bd84875f83c678f64f124937a278061e0e778 set=1|3 [HEADER] > Una cosa ~ one thing [LINE] > Lucy está en casa, con su nieta, Lin. ~ Lucy is at home with her granddaughter Lin [LINE] Speaker509: ¡Ay, no! Necesito pan para mi sándwich. ~ oh no I~need bread for my sandwich [LINE] Speaker508: ¿Vas al supermercado? ~ are~you~going to~the supermarket [LINE] Speaker509: Sí. ~ yes [MULTIPLE_CHOICE] > Lin and Lucy have a lot of bread. - Yes, that's right. + No, that's wrong. [LINE] Speaker508: ¡Ah, necesito una cosa del supermercado! ~ oh I~need one thing from~the supermarket [LINE] Speaker509: ¿Qué? ~ what [LINE] Speaker508: Un tomate, por~favor. ~ one tomato please [SELECT_PHRASE] > Select the missing phrase Speaker508: Es para [mi~ensalada]. ~ it's for my~salad - linda y salada + mi ensalada - la mía es salada [LINE] Speaker509: Está~bien. ~ OK [LINE] Speaker508: ¡Gracias! ~ thank~you [POINT_TO_PHRASE] > Choose the option that means "also." Speaker508: ¡Ah! Y (+también) (quiero) (tres) (manzanas)… ~ oh and also I~want three apples [LINE] Speaker509: Está~bien. ~ OK [LINE] Speaker508: … y jugo~de~naranja… ~ and orange~juice [LINE] Speaker509: Lin… ~ Lin [LINE] Speaker508: … y leche, por~favor. ~ and milk please [LINE] Speaker509: Mmm… tengo una idea. ~ hmm I~have an idea [ARRANGE] > Tap what you hear Speaker509: [(Aquí) (está) (el) (dinero), (Lin).] ~ here is the money Lin [LINE] Speaker508: ¡¿Qué?! ~ what [LINE] Speaker509: Yo quiero una cosa del supermercado: pan. ~ I want one thing from~the supermarket bread [MULTIPLE_CHOICE] > Why did Lucy give Lin money? - Lin is opening a sandwich shop. + She wants Lin to go to the supermarket. - Lin is going to a restaurant for lunch. [MATCH] > Tap the pairs - idea <> idea - una <> one - Yo <> I - nieta <> granddaughter - por favor <> please ================================================ FILE: database/stories/nl-en/0_1_es-en-el-pastel-de-dragones.txt ================================================ [DATA] fromLanguageName=The Dragon Cake icon=c2fbecd45802dc974e3ec727a0206cfec66fd87f set=0|1 #28|1 deleted=1 [HEADER] > El pastel~de~dragones ~ the dragon~cake [LINE] > Bea está con Vikram. Él está~decorando un~pastel para la~boda de Kara, una~amiga de Bea. ~ Bea is with Vikram he is~decorating a~cake for the~wedding of Kara a~friend of Bea's [LINE] Speaker507: ¡Me~encanta cómo decoraste el~pastel! ~ I~love how you~decorated the~cake [LINE] Speaker507: Los~novios están~montando dragones, ¡como los~personajes de la~serie~favorita~de~Kara~y~David! ~ the~bride~and~groom are~riding dragons like the~characters from Kara~and~David's~favorite~show [MULTIPLE_CHOICE] > The cake is decorated to look like a TV show. + Yes, that's right - No, that's wrong. [LINE] Speaker593: Kara es mi primera clienta y quiero hacer un excelente trabajo. ~ Kara is my first customer and I~want to~do an excellent job [ARRANGE] > Tap what you hear > [(Bea) (recibe) (un~mensaje) (de) (Kara).] ~ Bea receives a~message from Kara [LINE] Speaker507: ¡Ay, no! ¡Kara canceló la~boda! ~ oh no Kara canceled the~wedding [LINE] Speaker593: ¿Qué? ¿Por~qué? ~ what why [CONTINUATION] > What comes next? Speaker507: ¡David se~fue~del país con [otra mujer]! ~ David left~the country with another woman + otra mujer ~ another woman - un~dragón ~ a~dragon - muchos pasteles ~ many cakes [LINE] Speaker593: ¡Qué~horrible! ~ that's~terrible [LINE] Speaker507: No~lo~puedo~creer. ~ I~can't~believe~it [SELECT_PHRASE] > Select the missing phrase Speaker593: Lo~sé… Quería hacer [el~pastel~perfecto] para Kara. ~ I~know I~wanted to~make the~perfect~cake for Kara - el pastel de efecto - el pincel perfecto + el pastel perfecto [LINE] Speaker507: Tengo una~idea. ~ I~have an~idea [LINE] > Bea redecora el~pastel. ~ Bea redecorates the~cake [LINE] Speaker593: Espera… parece~que los~dragones se~están~comiendo~al novio. ~ wait it~looks~like the~dragons are~eating~the groom [MULTIPLE_CHOICE] > Bea redecorated the cake because… + …Kara canceled the wedding. - …Vikram wants to keep the dragons. - …Kara asked her to change it. [LINE] > Bea toma una~foto y se~la~envía a Kara. ~ Bea takes a~picture and sends~it to Kara [LINE] Speaker507: ¡Kara me~respondió! ~ Kara answered~me [LINE] Speaker593: ¿Qué dijo? ~ what did~she~say [LINE] Speaker507: Se~siente muy triste, pero le~encanta el~pastel. ~ she~feels very sad but she~loves the~cake [LINE] Speaker593: ¿De~verdad? ¡Estoy muy contento! ~ really I~am very happy [MULTIPLE_CHOICE] > Why does Kara like the cake after all? + The dragons are eating the groom. - She decided to marry Bea. - She's excited for her wedding. [MATCH] > Tap the pairs - los personajes <> the characters - Estoy <> I am - Por qué <> why - primera <> first - una foto <> a picture ================================================ FILE: database/stories/nl-en/0_2_es-en-es-amor.txt ================================================ [DATA] fromLanguageName=Is It Love? icon=d7461e0ccc18067f34ff9c385653ac769bde4875 set=0|2 #28|2 deleted=1 [HEADER] > ¿Es amor? ~ is~it love [LINE] > Es~hora~de un nuevo episodio de nuestro programa~de~citas: "¿Es amor?". ~ it's~time~for a new episode of our dating~show is~it love [LINE] > Eddy conoció~a muchas mujeres durante el~programa… ~ Eddy met many women during the~show [LINE] > … ¡pero ahora tiene~que elegir~a una! ~ but now he~has~to (to)~choose one [MULTIPLE_CHOICE] > On a television show, Eddy has to… ~ On to TV Show Eddy have to + …choose a woman to go out with. ~ choose to Woman to Go out with - …eat a lot of food. ~ Eat to Lot of Food - …drive really fast. ~ drive Really fast [LINE] > Él está~hablando~con Rosa en este~momento. ~ he is~talking~to Rosa in this~moment [LINE] Speaker414: No~puedo creer que tengo~que elegir~a una~sola mujer. ¡Esto es muy estresante! ~ I~can't (to)~believe that I~have~to (to)~choose just~one woman this is very stressful [LINE] Speaker338: ¡No, es fácil! Yo creo que deberías elegirme~a~mí. ~ no it's easy I think that you~should (to)~choose~me [ARRANGE] > Tap what you hear Speaker414: [(Pero) (es) (una) (decisión~difícil).] ~ but it's a difficult~decision [LINE] Speaker338: ¡¿Ah, sí?! ~ oh yes [SELECT_PHRASE] > Select the missing phrase Speaker338: Yo creía que [te~gustaba]… ~ I thought that you~liked~me - le gustaban - te gastabas + te gustaba [LINE] Speaker414: Sí, pero también~me~gusta Marina… ~ yes but I~also~like Marina [LINE] Speaker338: Bueno, sí, Marina es hermosa. ~ well yes Marina is beautiful [LINE] Speaker414: Ella es interesante y graciosa. ~ she is interesting and funny [ARRANGE] > Tap what you hear Speaker338: [(Y) (ella) (toca) (muy~bien~el~violín).] ~ and she plays the~violin~very~well [LINE] Speaker414: ¡Yo sé! A~ustedes~dos~les~encanta la~música. ~ I know you~both~love music [LINE] Speaker338: Sí, tenemos muchas cosas en común. ~ yes we~have many things in common [LINE] Speaker414: ¡Sí! A~las~dos~les~gustan las~películas~de~aventura y los~museos~de~arte. ~ yes you~both~like adventure~movies and art~museums [LINE] Speaker414: ¡Y las~dos escriben poemas! ~ and you~both write poems [LINE] Speaker338: Sí, sus~poemas son hermosos… ~ yes her~poems are beautiful [LINE] Speaker414: ¡No~sé qué hacer! ~ I~don't~know what to~do [LINE] Speaker338: Ya~sé lo~que deberías hacer… ~ I~know what you~should (to)~do [LINE] Speaker414: ¡¿Qué?! ~ what [LINE] Speaker338: Olvídate de Marina y de mí. ¡Elige~a alguien más! ~ forget about Marina and about me choose someone else [MULTIPLE_CHOICE] > Did you catch that? Rosa doesn't… - …like Marina's poetry. - …like funny movies. + …want Eddy to go out with her or Marina. [LINE] Speaker414: ¡¿Qué?! ¿Por~qué? ~ what why [LINE] Speaker338: Porque… yo quiero elegir~a Marina. ~ because I want to~choose Marina [LINE] Speaker414: ¿Ah? ~ oh [LINE] > En el próximo episodio de "¿Es amor?"… ¿¡qué va~a~decir~Marina!? ~ in the next episode of is~it love what is~Marina~going~to~say [MULTIPLE_CHOICE] > What did Rosa realize? - Marina writes terrible poetry. + She likes Marina and wants to go out with her. - She doesn't like Marina or Eddy. [MATCH] > Tap the pairs - A las dos les gustan <> you both like - creer <> believe - una <> one - sus poemas <> her poems - toca <> plays ================================================ FILE: database/stories/nl-en/0_3_es-en-la-pelea-de-boxeo.txt ================================================ [DATA] fromLanguageName=The Boxing Match icon=149224cb1a7c361279bece453d459dc57ba243a5 set=0|3 #28|3 deleted=1 [HEADER] > La pelea~de~boxeo ~ the boxing~match [LINE] > Lin y su abuela, Lucy, están~viendo una pelea~de~boxeo en un bar. ~ Lin and her grandmother Lucy are~watching a boxing~match at a bar [LINE] Speaker508: Abue, me~voy después~de esta bebida. ~ Grandma I'm~leaving after this drink [SELECT_PHRASE] > Select the missing word or phrase Speaker509: ¡Pero es la pelea~más~importante [del~año]! ~ but it's the most~important~match of~the~year + del año - del baño - tacaño [LINE] Speaker508: Odio el~boxeo. Es aburridísimo. ~ I~hate boxing it's very~boring [MULTIPLE_CHOICE] > Boxing is Lin's favorite sport. + No, that's wrong. - Yes, that's right. [LINE] Speaker509: ¿Qué~dices? ¡Es muy emocionante! ~ what~are~you~saying it's very exciting [POINT_TO_PHRASE] > Choose the option that means "fighting." Speaker508: (Son) (solo) dos (hombres) (+peleando) con guantes~enormes. ~ they~are just two men fighting with huge~gloves [LINE] Speaker509: No~son solo hombres… ¡Son atletas! ~ they~are~not just men they~are athletes [LINE] Speaker508: Solo~quieren ganar un estúpido trofeo. ~ they~just~want to~win a stupid trophy [MULTIPLE_CHOICE] > Lin says that boxing is… - …a good way to make new friends. + …just two men fighting for a trophy. - …how she lost a tooth. [LINE] > Lin pone su vaso sobre la~mesa y se~levanta. ~ Lin places her glass on the~table and gets~up [LINE] Speaker509: ¿Adónde~vas? ~ where~are~you~going [CONTINUATION] > What comes next? Speaker508: Voy~a casa a~ver "¿Es amor?" en [la~tele]. ~ I'm~going home to~watch is~it love on TV - el~sándwich ~ the~sandwich + la~tele ~ TV - mis calcetines ~ my socks [LINE] Speaker509: ¿El~programa donde veinte hombres se~pelean por la misma mujer? ~ the~show where twenty men fight for the same woman [ARRANGE] > Tap what you hear Speaker509: ¡[(Y) (lo~peor) (es~que) (no~ganan) (ningún~trofeo)!] ~ and the~worst is~that they~don't~win any~trophy [LINE] Speaker508: No, pero pelean por amor. ~ no but they~fight for love [LINE] Speaker509: ¿Y tú dices que el~boxeo es aburrido? ~ and you say that boxing is boring [MULTIPLE_CHOICE] > Did you catch that? Lucy thinks… - …twenty women are boxing one man. - …Lin would be a great boxer. + …fighting over a woman is boring. [MATCH] > Tap the pairs - muy <> very - no ganan <> they don't win - es <> it's - la mesa <> the table - aburridísimo <> very boring ================================================ FILE: database/stories/nl-en/0_4_es-en-el-neumatico-pinchado.txt ================================================ [DATA] fromLanguageName=The Flat Tire icon=76e5ba84900b1c2ebd321eafd9329980919ccede set=0|4 #28|4 deleted=1 [HEADER] > De lekke~band ~ the flat~tire [LINE] > Vikram y Priti están de~vacaciones. ~ Vikram and Priti are on~vacation [LINE] > Ellos están~manejando y el~carro hace un ruido~fuerte. ~ they are~driving and the~car makes a loud~noise [LINE] > Vikram para el~carro a~un~lado de la~carretera. ~ Vikram stops the~car at~the~side of the~road [LINE] Speaker593: ¡Ay, no! ~ oh no [LINE] Speaker560: ¿Cuál es el~problema? ~ what is the~problem [LINE] Speaker593: El~carro tiene un neumático~pinchado y no~tengo otro. ~ the~car has a flat~tire and I~don't~have another~one [MULTIPLE_CHOICE] > Uh oh! What's wrong with the car? - It ran out of gas. - It's too small. + It has a flat tire. [LINE] Speaker560: Podemos caminar. El~apartamento está a~dos~kilómetros. ~ we~can (to)~walk the~apartment is two~kilometers~away [LINE] Speaker593: ¡Pero está~lloviendo! ~ but it's~raining [CONTINUATION] > What comes next? Speaker560: ¡No~hay problema! ¡Tengo [un~paraguas]! ~ there~is~no problem I~have an~umbrella - el~pelo~muy~bonito ~ very~nice~hair + un~paraguas ~ an~umbrella - dos gatos ~ two cats [SELECT_PHRASE] > Select the missing phrase > Ellos comienzan~a [caminar~en~la~lluvia]. ~ they begin to~walk~in~the~rain - caminar con la novia - cocinar en la lluvia + caminar en la lluvia [LINE] Speaker593: Lo~siento~mucho, Priti. ¡Estas~son~nuestras~peores~vacaciones! ~ I'm~so~sorry Priti this~is~our~worst~vacation [LINE] Speaker560: ¡Por~supuesto~que~no! Estas~vacaciones~me~recuerdan~a nuestra luna~de~miel. ~ of~course~not this~vacation~reminds~me~of our honeymoon [LINE] Speaker593: ¡Sí! ~ yes [LINE] Speaker560: Tuvimos problemas con el~carro y caminamos diez kilómetros para llegar al hotel. ~ we~had problems with the~car and we~walked ten kilometers to (to)~arrive at~the hotel [LINE] Speaker593: Sí, y yo olvidé mis llaves en la~habitación. ¿Recuerdas que tuve~que entrar por la~ventana? ~ yes and I forgot my keys in the~room do~you~remember that I~had~to (to)~get~in through the~window [MULTIPLE_CHOICE] > Hmm… why did Vikram climb through the window? + He didn't have the room key. - He was training to be a spy. - Priti locked him out. [LINE] Speaker560: Sí, y después abriste la~puerta con flores en las~manos. ~ yes and then you~opened the~door with flowers in your~hands [LINE] Speaker593: ¡Qué~día~tan~hermoso! ~ what~a~beautiful~day [ARRANGE] > Tap what you hear > [(Ellos) (llegan) (al) (apartamento).] ~ they arrive at~the apartment [LINE] Speaker560: ¡Llegamos! Vamos~a~entrar, tengo~frío. ~ we~arrived let's~go~in I'm~cold [LINE] Speaker593: Espera… ¿dónde están las~llaves? ~ wait where are the~keys [MULTIPLE_CHOICE] > How is this vacation like Vikram and Priti's honeymoon? + They had a car problem and Vikram forgot the keys. - They lost their phones and needed a map. - They swam in the ocean and made a nice dinner. [MATCH] > Tap the pairs - caminar en la lluvia <> to walk in the rain - Priti <> Priti - y <> and - están manejando <> are driving - hace <> makes ================================================ FILE: database/stories/nl-en/1_1_es-buenos-dias.txt ================================================ [DATA] fromLanguageName=Good morning icon=783305780a6dad8e0e4eb34109d948e6a5fc2c35 set=1|1 approvals=11,12 public=1 icon_Jan=https://stories-cdn.duolingo.com/image/f118359885a5dc9babe4808c746e1c15ea9cd6f4.svg speaker_Marian=Lotte speaker_Jan=Ruben [HEADER] > Goedemorgen! ~ Good~morning $43/2725ef44.mp3;11,50 [LINE] > Jan is thuis met zijn vrouw, Marian. ~ ~ is home with his wife ~ $43/2a532e34.mp3;3,50;3,350;6,200;4,350;5,162;6,200;7,675 [LINE] Speaker15: Hoi Marian. ~ Hi Marian $43/2c75faa2.mp3;3,50;7,325 [LINE] Speaker292: Morgen, schat. ~ Morning darling $43/ab6157c8.mp3;6,50;6,712 [MULTIPLE_CHOICE] > What does "schat" mean? + Darling - Friend - Brother [LINE] Speaker292: Weet~jij waar mijn lesboek Engels is? ~ do~you~know Where my textbook English is $43/eb4efbec.mp3;4,50;4,337;5,275;5,138;8,200;7,525;3,375 [LINE] Speaker15: Sorry? ~ Sorry $43/a6e81e90.mp3;5,50 [SELECT_PHRASE] > Select the missing phrase Speaker292: Ik heb [een examen~Engels] op de universiteit. ~ I have an English~exam at the university $43/bbe45be2.mp3;2,50;4,175;4,175;7,125;7,662;3,413;3,150;13,50 - en Engels examen + een examen Engels - een Engelse element [LINE] Speaker292: Waar is toch mijn boek?! ~ Where is (emphazising~word) my book $43/dd754028.mp3;4,50;3,337;5,188;5,150;5,275 [LINE] Speaker15: Het ligt daar, op de tafel. ~ It is over~there on the table $43/04ded8de.mp3;3,50;5,225;5,237;3,488;3,150;6,100 [POINT_TO_PHRASE] > Click on the option meaning "tired". Speaker292: (Ik) (ben) (+moe) (Jan{jan}). (Ik) (werk) (zoveel). ~ I am tired ~ I work so~much $43/297a01e6.mp3;2,50;4,162;4,250;4,188;3,1287;5,175;7,400 [LINE] Speaker15: Wil~je een~kop koffie? ~ Do~you~want a~cup~of coffee $43/3631cbb2.mp3;3,50;3,300;4,75;4,87;7,250 [LINE] Speaker292: Ja, graag! ~ Yes thanks $43/39f70b04.mp3;2,50;6,475 [ARRANGE] > Tap what you hear Speaker15: [(Met) (of) (zonder) (melk)]? ~ With or without milk $43/57ec8bd6.mp3;3,50;3,325;7,125;5,350 [LINE] Speaker292: Zwart, graag. ~ Black, please. $43/54729340.mp3;5,50;6,600 [LINE] Speaker15: Alsjeblieft. ~ Here~you~are $43/376d2726.mp3;11,50 [LINE] > Marian doet suiker in haar kopje. ~ ~ puts sugar into her cup $43/6484c3ea.mp3;6,50;5,550;7,187;3,475;5,125;6,238 [CONTINUATION] > What comes next? > Ze drinkt [haar koffie]. ~ She drinks her coffee $43/68a9b9e4.mp3;2,50;7,287;5,363;7,237 - haar melk + haar koffie - haar boek [LINE] Speaker292: Gadver! ~ Damn $43/6e0577e8.mp3;6,50 [LINE] Speaker15: Wat? ~ What $43/714cb542.mp3;3,50 [LINE] Speaker292: Het is zout, geen suiker! ~ That is salt not sugar $43/71d81310.mp3;3,50;3,200;5,187;5,538;7,375 [LINE] Speaker15: Je bent echt heel moe, Marian. ~ You are really very tired ~ $43/80f99fe4.mp3;2,50;5,150;5,275;5,212;4,275;7,438 [CONTINUATION] > Complete the sentence > Marian was zo moe, dat ze [zout in haar koffie deed in~plaats~van suiker]. ~ ~ was so tired that she put~salt in her coffee (put) instead~of sugar $43/8df42b9c.mp3;6,50;4,537;3,213;4,212;4,563;3,187;5,125;3,350;5,113;7,162;5,375;3,275;7,125;4,338;7,150 - in~slaap~viel tijdens~het~praten met Jan. ~ fell~asleep while~talking to ~ + zout in haar koffie deed in~plaats~van suiker. ~ put~salt in her coffee (put) instead~of sugar - haar kopje liet~vallen terwijl ze~aan~het~drinken~was. ~ dropped~her cup (dropped) when she~was~drinking [MATCH] > Tap the pairs - with <> met - at home <>thuis - the wife <> de vrouw (de echtgenote) - darling <> schat - the salt <> het zout ================================================ FILE: database/stories/nl-en/1_2_es-una-cita.txt ================================================ [DATA] fromLanguageName=A date icon=df24f7756b139f6eda927eb776621b9febe1a3f1 set=1|2 approvals=11,12 public=1 speaker_Julie=Lotte speaker_Anne=Lotte speaker_Paul=Ruben [HEADER] > Een Afspraakje ~ A date $56/badfe656.mp3;3,50;11,212 [LINE] > Julia zit~in een restaurant voor een afspraakje. ~ ~ is~at a restaurant for a date $56/507cb990.mp3;5,50;4,512;3,250;4,138;11,87;5,600;4,175;11,125 [LINE] Speaker100: Wat zou~je willen eten? ~ What would~you like for~dinner? $56/ef375bfe.mp3;3,50;4,212;3,200;7,113;5,250 [MULTIPLE_CHOICE] > Paul wants to know ... - ... what the food is. + ... what Julie wants to eat. - ... how much the food costs [LINE] Speaker336: Een salade. ~ A salad $56/b4270222.mp3;3,50;7,237 [LINE] Speaker336: Ik eet geen vlees. ~ I eat no meat $56/b1703792.mp3;2,50;4,262;5,200;6,300 [LINE] Speaker100: Ben je vegetariër? ~ Are you a~vegetarian $56/8e8f50e6.mp3;3,50;3,175;11,100 [LINE] Speaker336: Ja. ~ Yes $56/afa9d058.mp3;2,50 [LINE] Speaker100: Ik ook! ~ Me too $56/943c340a.mp3;2,50;4,212 [LINE] Speaker336: Oh, leuk. ~ ~ nice $56/ab4ac5bc.mp3;2,50;5,537 [LINE] Speaker336: Ben je Nederlands? ~ Are you Dutch $56/a8684f4a.mp3;3,50;3,275;11,75 [ARRANGE] > Put the words in the right order: Speaker100: [(Nee), (ik) (ben) (Engels)] ~ No I am English $56/971ccce8.mp3;3,50;3,637;4,150;7,225 [LINE] Speaker100: Mijn vader is Canadees en mijn moeder Spaans. ~ My father is Canadian and my mother Spanish $56/994bea58.mp3;4,50;6,250;3,437;9,175;3,650;5,150;7,213;7,275 [LINE] Speaker336: Ik ben Canadees! ~ I am Canadian $56/9f96a0ba.mp3;2,50;4,237;9,213 [LINE] Speaker100: Ik houd~van Canada! ~ I love Canada $56/d846568a.mp3;2,50;5,200;4,262;7,188 [SELECT_PHRASE] > Choose the best answer: Speaker100: Heb~je een [huisdier]? ~ Do~you~have a pet $56/ddacf426.mp3;3,50;3,225;4,125;9,100 + huisdier - huisdieren - thuisbeest [LINE] Speaker336: Ja, ik heb drie katten en een hond. ~ Yes I have three cats and a dog $56/e1748498.mp3;2,50;3,712;4,150;5,213;7,262;3,350;4,113;5,125 [LINE] Speaker100: Ik heb ook katten en een hond, Anne! ~ I have also cats and a dog ~ $56/e5030206.mp3;2,50;4,200;4,212;7,200;3,375;4,150;5,125;5,563 [LINE] > Julia kijkt verrast naar Paul. ~ ~ looks surprised to ~ $56/18e9471e.mp3;5,50;6,475;8,325;5,450;5,162 [LINE] Speaker336: Anne? Maar ik heet Julia ! ~ ~ But I am~called ~ $56/30c86932.mp3;4,50;5,1237;3,263;5,125;6,275 [LINE] Speaker336: En jij bent Sebastiaan toch? ~ And you are ~ right $56/7cd97f28.mp3;2,50;4,262;5,250;11,250;5,750 [LINE] Speaker100: Wat? ~ What $56/00ae35fc.mp3;3,50 [MULTIPLE_CHOICE] > Paul en Julie zitten allebei met de verkeerde persoon aan tafel. + Yes, that's true. - No, that's not right. [LINE] > Een vrouw komt het restaurant binnen. ~ A woman enters the restaurant (komt~binnen=enters) $56/88899a24.mp3;3,50;6,175;5,375;4,225;11,112;7,638 [LINE] Speaker101: Hoi! Ben jij Paul? Ik ben Anne. ~ Hi Are you ~ I am ~ $56/06a4ba58.mp3;3,50;4,1350;4,250;5,150;3,1312;4,175;5,225 [LINE] Speaker100: Euh, nee. Ik ben Sebastiaan. ~ ~ no I am ~ $56/0d46a074.mp3;3,50;4,637;3,825;4,213;11,200 [MULTIPLE_CHOICE] > Paul... - ... is moe van het praten met Julia. - ... heet eigenlijk Sebastiaan. + ... liegt tegen Anne omdat hij Julia leuk vindt. [MATCH] > Tap the pairs - to be called <> heten - the vegetarian <> de vegetariër - the meat <> het vlees - the pet <> het huisdier - the woman <> de vrouw ================================================ FILE: database/stories/nl-en/1_3_es-una-cosa.txt ================================================ [DATA] fromLanguageName=One thing icon=717bd84875f83c678f64f124937a278061e0e778 set=1|3 approvals=11,12 public=1 icon_Jorrit=https://stories-cdn.duolingo.com/image/8dedb551515218979bffa2ba590906d0ed1ee462.svg speaker_Maartje=Lotte speaker_Jorrit=Ruben [HEADER] > Iets $79/22e5c25c.mp3;4,50 [LINE] > Maartje is thuis met haar broer Sergio. ~ ~ is at~home with her brother ~ $79/24ed0aba.mp3;7,50;3,550;6,200;4,375;5,150;6,187;7,275 [LINE] Speaker856: Oh! Er is geen brood meer. ~ Oh There is no bread left $79/2970a7ea.mp3;2,50;3,1150;3,162;5,200;6,375;5,400 [MULTIPLE_CHOICE] > Maartje saw there is more bread. - That’s true. + That’s not true. [LINE] Speaker366: Ga~je naar de supermarkt? ~ Are~you~going to the supermarket $79/2ff7d1ce.mp3;2,0;3,174;5,90;3,166;11,65 [LINE] Speaker856: Ja, ik wil brood voor~de~lunch. ~ Yes I want some~bread for~the~lunch $79/35cc9a08.mp3;2,50;3,487;4,175;6,213;5,375;3,162;6,100 [LINE] Speaker366: Kun je iets voor~me kopen? ~ Can you (something) (for~me) buy~something~for~me $79/3b0db506.mp3;3,0;3,125;5,102;5,173;3,157;6,105 [MULTIPLE_CHOICE] > Sergio wants to know, if - Maartje can buy bread + Maartje can buy something for him - Maartje can buy cheese for him [LINE] Speaker856: Wat~dan? ~ What $79/3fb21174.mp3;3,50;4,262 [SELECT_PHRASE] > Choose the correct word: Speaker366: [Een tomaat], alsjeblieft. ~ a tomato please $79/42a5ddd4.mp3;3,0;7,104;13,403 + Een tomaat - Twee tomaat [LINE] Speaker366: Voor mijn salade. ~ for my salad $79/a372eff0.mp3;4,0;5,220;7,174 [LINE] Speaker856: Oké. ~ OK $79/4762623e.mp3;3,50 [LINE] Speaker366: Bedankt. ~ Thanks $79/af744664.mp3;7,0 [LINE] Speaker366: Oh ja! En drie broodjes. ~ Oh yes And three bread~rolls $79/b87f36d8.mp3;2,0;3,119;4,263;5,305;9,180 [LINE] Speaker856: Oké… ~ OK $79/4995bfec.mp3;3,50 [LINE] Speaker366: En sinaasappelsap… ~ And orange~juice $79/c8e088b0.mp3;2,0;14,104 [LINE] Speaker856: Sergio{Serdzjo}... $79/5bf229fa.mp3;7,50 [LINE] Speaker366: ...en koffie, graag. ~ and coffee please $79/aa3d9d72.mp3;5,0;7,424;6,276 [LINE] Speaker856: Eh… Ik heb een idee. ~ Eh I have an idea $79/619d10c2.mp3;2,50;3,1025;4,112;4,200;5,150 [CONTINUATION] > Which word links the two sentences together to make one logical sentence? Speaker856: Ik blijf thuis [want] jij gaat naar de supermarkt. ~ I will~stay at~home because you will~go to the supermarket $79/631c58f4.mp3;2,50;6,162;6,388;5,500;4,212;5,275;5,375;3,163;11,87 + want ~ want - omdat ~ because - dagenlang ~ for~days [LINE] Speaker366: Waarom? ~ why $79/f992b5b4.mp3;6,0 [LINE] Speaker856: Ik heb maar één ding nodig. Brood. ~ I need~(+nodig) only one thing (+hebben)~need Bread $79/6653e5b4.mp3;2,50;4,162;5,188;4,300;5,287;6,200;6,1513 [MULTIPLE_CHOICE] > Maartje wants Sergio to go to the supermarket because... - she doesn’t know where to find the coffee. + Sergio wants a lot of things. - she can buy bread at the bakery. [MATCH] > Put the right words together - something <> iets - the orange juice <> het sinaasappelsap - to buy <> kopen - the brother <> de broer - the idea <> het idee ================================================ FILE: database/stories/nl-en/1_4_es-en-la-luna-de-miel.txt ================================================ [DATA] fromLanguageName=The honeymoon icon=7e5d271488d31d6f1d0c503512e642ca7effe84f set=1|4 approvals=11,12 public=1 speaker_Sophie=Lotte speaker_Marie=Lotte speaker_Taxichauffeur=Ruben [HEADER] > De Huwelijksreis $570/33a29958.mp3;2,50;14,125 [LINE] > Sophie zit in een taxi. ~ ~ is(~sits) in a taxi $570/380f2592.mp3;6,50;4,562;3,238;4,125;5,125 [LINE] Speaker113: Goedemorgen. ~ Good~morning $570/af5a381c.mp3;11,50 [SELECT_PHRASE] > Fill in the missing words. Speaker439: Goedemorgen, naar [het~vliegveld] alstublieft. ~ Good~morning to the~airport please ~ $570/b2410394.mp3;11,50;5,1012;4,200;10,100;12,725 - de vliegtuigen + het vliegveld - de vliegen [LINE] Speaker113: Is~goed. ~ Alright $570/b68892f0.mp3;2,50;5,287 [LINE] Speaker113: Reist~u voor uw werk? ~ Are~you~travelling for (your) work $570/b7bb48fc.mp3;5,50;2,450;5,62;3,213;5,137 [LINE] Speaker439: Nee. ~ No $570/bac6a654.mp3;3,50 [LINE] Speaker439: Ik heb een vliegticket naar Toulouse{toeloeze}. ~ I have a ticket for ~ $570/d3771e54.mp3;2,50;4,175;4,187;12,100;5,675;9,175 [LINE] Speaker439: Ik heb~zelfs twee vliegtickets naar Toulouse{toeloeze}. ~ I even~have two tickets for ~ $570/e08523f2.mp3;2,50;4,150;6,175;5,525;13,375;5,800;9,150 [LINE] Speaker113: Toulouse{toeloeze} is een mooie stad! ~ ~ is a beautiful city $570/f3b58c32.mp3;8,50;3,550;4,187;6,113;5,375 [LINE] Speaker439: Het is voor mijn huwelijksreis. ~ It is for my honeymoon $570/f89947ac.mp3;3,50;3,200;5,200;5,175;14,287 [MULTIPLE_CHOICE] > Why is Sophie going to Toulouse? - She is going to work there. + She is going on her honeymoon. - She's moving there. [LINE] Speaker113: Waar is uw man? ~ Where is your husband $570/faa2048a.mp3;4,50;3,275;3,175;4,125 [LINE] Speaker439: Mijn vrouw. ~ My wife $570/fc242b4e.mp3;4,50;6,350 [LINE] Speaker439: Zij wil~niet naar Toulouse{toeloeze} met mij. ~ She doesn't~want~to~go to ~ with me $570/02aaaeac.mp3;3,50;4,262;5,200;5,313;9,162;4,525;5,200 [MULTIPLE_CHOICE] > Sophie's wife ... - ... is already in Toulouse. - ... is in the taxi with her. + ... doesn't want to go to Toulouse with her. [LINE] Speaker439: Het is een zwarte dag voor mij. ~ It is a dark day for me $570/08d95b0c.mp3;3,50;3,200;4,212;7,63;4,512;5,250;5,175 [ARRANGE] > Zet de woorden in de juiste volgorde: Speaker113: [(Oh) (dat) (spijt) (me) (verschrikkelijk)]. $570/09ce9496.mp3;2,50;4,225;6,212;3,400;16,125 [LINE] Speaker113: We zijn bij het vliegveld aangekomen. ~ We have~arrived at the airport (arrived) $570/0c8f51f2.mp3;2,50;5,150;4,262;4,125;10,150;11,538 [LINE] > Een vrouw komt~aanlopen met haar reiskoffer. ~ A woman arrives with her suitcase $570/11079c80.mp3;3,50;6,175;5,337;4,325;4,200;5,150;11,150 [LINE] Speaker439: Marie? $570/1506c5cc.mp3;5,50 [LINE] Speaker1186: Sophie! $570/1a07a10e.mp3;6,50 [LINE] Speaker1186: Het~spijt~me! ~ I~am~sorry $570/1ec8dcee.mp3;3,50;6,200;3,450 [LINE] Speaker1186: Ik houd~van je! ~ I love you $570/21459fa2.mp3;2,50;5,162;4,313;3,162 [MULTIPLE_CHOICE] > What does Marie mean? - I am late! - I hate you! + I love you! [LINE] Speaker439: Ik houd~ook~van~jou! ~ I love~you~too $570/25326a50.mp3;2,50;5,137;4,388;4,200;4,175 [LINE] Speaker1186: Op naar Toulouse{toeloeze}. ~ Let's~go to ~ $570/2e4fa95e.mp3;2,50;5,162;9,213 [LINE] Speaker113: Goede~reis! ~ Have~a~nice~trip $570/325bca28.mp3;5,50;5,387 [MULTIPLE_CHOICE] > What happened when Sophie got to the airport? - Her taxidriver agreed to go on vacation with her. + Her wife, Marie, was already there and ready to go to Toulouse. - Her wife, Marie, called her on the phone. [MATCH] > Tap the pairs. - have a nice trip <> goede reis! - to travel <> reizen - the airport <> het vliegveld - a dark day <> een zwarte dag - the honeymoon <> de huwelijksreis ================================================ FILE: database/stories/nl-en/2_1_es-en-la-chaqueta-roja.txt ================================================ [DATA] fromLanguageName=The red jacket icon=5361833c123aec9adfa60b0dc63398cd1aa49ef2 set=2|1 approvals=11,12 icon_Natalie=https://stories-cdn.duolingo.com/image/ad3623a2d5ee767d1536ff972bfb9961b40df6ca.svg speaker_Mark=Ruben speaker_Natalie=Lotte [HEADER] > In de Kledingwinkel $623/ad770e28.mp3;2,0;3,135;14,76 [LINE] > Natalie is in een kledingwinkel met haar broer, Mark. ~ ~ is in a clothing~store with her brother ~ $623/b0b5a680.mp3;7,0;3,583;3,157;4,110;14,126;4,743;5,201;6,195;6,352 [LINE] Speaker3160: Deze kleren zijn leuk! ~ These clothes are nice $623/b4e101e6.mp3;4,50;7,437;5,425;5,163 [LINE] Speaker439: Mark, ze zijn duur! ~ ~ they are expensive $623/b870024e.mp3;4,50;3,612;5,150;5,250 [MULTIPLE_CHOICE] > Natalie thinks the clothes are cheap. + No, that's not right. - Yes, that's true. [LINE] Speaker3160: Wauw! ~ Wow $623/bdc36114.mp3;4,50 [LINE] Speaker3160: Deze jas is gaaf! ~ This jacket is awesome $623/c107de86.mp3;4,50;4,437;3,275;5,175 [MULTIPLE_CHOICE] > Mark thinks .. + .. the jacket is great. - .. the shirt is cheap. - .. the skirt is great. [LINE] Speaker3160: En ik houd~van deze kleur. ~ And I love this colour $623/c60f70d8.mp3;2,50;3,175;5,137;4,263;5,137;6,313 [LINE] Speaker439: Mark... $623/c9d99176.mp3;4,50 [LINE] Speaker3160: Deze is perfect voor mij! ~ This~one is perfect for me $623/cce385c0.mp3;4,50;3,525;8,162;5,525;4,138 [SELECT_PHRASE] > Tap the missing words. Speaker3160: Ik wil [deze jas.] $623/d06a3b26.mp3;2,50;4,225;5,200;4,362 + deze jas. - die kat. - dit jas. [LINE] Speaker439: Mark! $623/d41c8170.mp3;4,50 [LINE] Speaker3160: Wat? ~ What $623/d8584936.mp3;3,50 [LINE] Speaker439: De jas is vijfhonderd euro. ~ The jacket is five~hundred euros $623/db1308b4.mp3;2,50;4,125;3,437;12,163;5,800 [MULTIPLE_CHOICE] > How much does the jacket cost? - Five million euros. - Five euros. + Five hundred euros. [LINE] Speaker439: Hij is te duur. ~ It is too expensive $623/e206505e.mp3;3,50;3,312;3,188;5,125 [LINE] Speaker3160: Vijfhonderd euro?! ~ Five~hundred euros $623/e5b6f1a4.mp3;11,50;5,687 [LINE] Speaker439: Helaas.. ~ Unfortunately $623/eb7eea38.mp3;6,50 [LINE] Speaker3160: Ik heb een idee! ~ I have an idea $623/eebbd95e.mp3;2,50;4,212;4,200;5,125 [LINE] Speaker3160: Mijn verjaardag is binnenkort. ~ My birthday is soon $623/f1ee5426.mp3;4,50;11,275;3,562;11,150 [MULTIPLE_CHOICE] > What does Mark mean? + It will be his birthday soon. - He's going on vacation soon. - It's his wedding anniversary soon. [LINE] Speaker439: Ja, dus? ~ Yes so $623/f5ef0426.mp3;2,50;4,437 [LINE] Speaker3160: En jij bent mijn zus! ~ And you are my sister $623/fa3bcaf0.mp3;2,50;4,212;5,275;5,213;4,225 [LINE] Speaker3160: Je werkt in Parijs .. ~ You work in Paris $623/fe58971c.mp3;2,50;6,150;3,300;7,125 [LINE] Speaker3160: Dus je koopt daar .. ~ So you will~buy there $623/13e086ce.mp3;3,50;3,250;6,112;5,313 [LINE] Speaker439: .. een goedkope jas voor mijn broer? ~ a cheap jacket for my brother $623/1a6cb514.mp3;3,50;9,125;4,675;5,525;5,162;6,275 [MULTIPLE_CHOICE] > What will Natalie do in Paris? + Buy a cheap jacket for her brother's birthday. - Buy the jacket for five hundred euros for her brother. - Work as a clothing designer. [MATCH] > Tap the pairs. - the jacket <> de jas - cool <> gaaf - the birthday <> de verjaardag - soon <> binnenkort - cheap <> goedkoop ================================================ FILE: database/stories/nl-en/2_2_es-en-el-pasaporte.txt ================================================ [DATA] fromLanguageName=The Passport icon=643347b755001a918130ddf5f6d3e914e63a00ce set=2|2 approvals=11,12 [HEADER] > Het Paspoort ~ the passport $5388/8c176cec.mp3;3,50;9,225 [LINE] > Vikram en zijn vrouw Priti zijn op het vliegveld. ~ Vikram and his wife Priti are at the airport $5388/849fdd30.mp3;6,0;3,443;5,124;6,273;6,262;5,466;3,262;4,130;10,98 [LINE] Speaker593: O~jee, waar is mijn paspoort? ~ oh~no where is my passport $5388/7bd39938.mp3;1,0;4,179;6,320;3,276;5,155;9,155 [LINE] Speaker593: Het~is niet hier! ~ it's not here $5388/5696412e.mp3;3,0;3,134;5,155;5,211 [LINE] Speaker560: Vikram… ~ Vikram $5388/98a101c4.mp3;6,0 [SELECT_PHRASE] > Select the missing phrase Speaker593: Het~zit niet [in mijn tas]… ~ it's not in my bag $5388/598188f8.mp3;3,0;4,149;5,190;3,200;5,100;4,170 + in mijn tas - in mijn jas - in zijn tas [LINE] Speaker593: En het~zit niet in mijn jack{jek} ~ and it's not in my jacket $5388/6479ecfa.mp3 [LINE] Speaker560: Vik… ~ Vik $5388/d58a9c8a.mp3;4,0 [LINE] Speaker593: Het ligt nog in de taxi! ~ it 's still in the taxi $5388/67ee9840.mp3;3,0;5,149;4,210;3,170;3,121;5,85 [MULTIPLE_CHOICE] > Vikram thinks his passport is in the taxi. + Yes, that's right. - No, that's wrong. [LINE] > Vikram rent naar~buiten om de taxi te~zoeken. ~ Vikram runs outside to~look~for the taxi (to~look~for) $5388/7048c7b8.mp3;6,50;5,537;5,300;7,150;3,438;3,125;5,112;3,375;7,125 [LINE] Speaker560: Vik, nee! ~ Vik no $5388/837e31d0.mp3;6,0;5,454 [ARRANGE] > Tap what you hear > [(Priti) (rent) (achter) (Vikram) (aan).] ~ Priti runs achter~...~aan:~after Vikram achter~...~aan:~after $5388/977c7c78.mp3;5,0;5,442;7,235;7,292;4,450 [LINE] Speaker593: O nee, de taxi is er~niet~meer ~ oh no the taxi is not~there~anymore $5388/75c069c6.mp3;1,0;4,174;4,300;5,233;3,430;3,150;5,65;5,215 [LINE] Speaker593: Wat een ramp! ~ What a disaster! $5388/79b10194.mp3;3,0;4,200;5,110 [LINE] Speaker560: Vikram! ~ Vikram $5388/b8b58dda.mp3;6,0 [LINE] Speaker560: Je paspoort ligt~niet in de taxi. ~ your passport is~not in the taxi $5388/bcc9d9e4.mp3;2,0;9,144;5,556;5,270;3,239;3,135;5,115 [POINT_TO_PHRASE] > Choose the option that means "passport." Speaker560: (Je) (hebt) (je) (+pas) (in) (je) (hand) ~ you have your passport in your hand $5388/c19d2142.mp3;2,0;5,129;3,236;4,70;3,243;3,100;5,135 [LINE] Speaker593: Oh… ~ oh $5388/7e3c568c.mp3;2,0 [LINE] Speaker593: Dank je lieverd ~ thank you my~love $5388/85d26422.mp3;4,0;3,310;8,55 [MULTIPLE_CHOICE] > Where was Vikram's passport? - in the taxi - at his house + in his hand [MATCH] > Tap the pairs - het passport <> de pas - is <> staat, zit, ligt - the disaster <> de ramp - the jacket <> het jack - your <> je, jouw ================================================ FILE: database/stories/nl-en/2_3_es-en-una-familia-muy-grande.txt ================================================ [DATA] fromLanguageName=A Very Big Family icon=9a2dcd1a9eaff04d1e9b4338e9afcead94c365bf set=2|3 approvals=11,12 speaker_Moeder=Lotte speaker_Melissa=Lotte speaker_Olivia=Lotte [HEADER] > Een grote familie ~ a big family $924/63aa40dc.mp3;3,50;6,150;8,450 [LINE] > Olivia ontmoet Melissa haar familie. ~ ~ meets ~ 's family $924/688768e6.mp3;6,50;8,587;8,400;5,513;8,187 [MULTIPLE_CHOICE] > Olivia is meeting Melissa's family. - No, that's not right. + Yes, that's true. [LINE] Speaker341: Melissa! Mijn lievelingsdochter! ~ ~ My favorite~daughter $924/6c508f84.mp3;7,0;6,615;18,435 [LINE] Speaker125: Mama, je hebt maar één dochter! ~ Mum you have only one daughter $924/71120b6a.mp3;4,50;3,700;5,100;5,287;4,275;8,238 [LINE] Speaker125: Dit is mijn vriendin, Olivia. ~ This is my girlfriend Olivia $924/739892fa.mp3;3,50;3,312;5,163;9,237;7,738 [LINE] Speaker856: Hallo! ~ Hello $924/79adba58.mp3;5,50 [LINE] Speaker125: Olivia, dit is mijn broer, Thomas. ~ ~ this is my brother ~ $924/7d24a282.mp3;6,50;4,987;3,200;5,175;6,263;7,637 [LINE] Speaker856: Dag Thomas! ~ Hi Thomas $924/818db6d8.mp3;3,50;7,262 [LINE] Speaker125: En dit is mijn oma. ~ And this is my grandmother $924/94334032.mp3;2,50;4,237;3,250;5,163;4,275 [LINE] Speaker856: Goedendag. ~ Hello $924/852b4576.mp3;9,50 [LINE] Speaker125: Dit zijn mijn vader en mijn broer, David. ~ These are my father and my brother ~ $924/9fc27af8.mp3;3,50;5,312;5,188;6,250;3,462;5,125;6,275;6,650 [SELECT_PHRASE] > Finish the sentence. Speaker856: Hallo. Hoe [gaat het met jullie?] $924/8b57dc2a.mp3;5,50;4,1425;5,175;4,312;4,100;7,175 - gaan met jullie? + gaat het met jullie? - ga jullie? [LINE] Speaker125: Mijn opa, Dorian. ~ My grandfather ~ $924/adf627fa.mp3;4,50;4,387;7,750 [LINE] Speaker856: Aangenaam! ~ Nice~to~meet~you $924/8e14d8d2.mp3;9,50 [ARRANGE] > Put the words in the right order. Speaker856: [(Je) (hebt) (een) (grote) (familie)] $924/9051aada.mp3;2,50;5,187;4,288;6,87;8,525 [LINE] Speaker341: Maar maar één dochter! ~ But only one daughter $924/9594e7d2.mp3;4,0;5,164;4,261;8,238 [LINE] Speaker125: Mama? ~ Mum $924/9a863c78.mp3;4,50 [LINE] Speaker341: Ah, natuurlijk. Ik heb nu twee dochters! ~ Ah of~course I have now two daughters $924/dcd3ef4e.mp3;2,0;12,254;4,806;4,390;3,170;5,160;9,305 [MULTIPLE_CHOICE] > What does Melissa's mother mean with 'ik heb nu twee dochters'? - She just gave birth to a second daughter. + She considers Olivia part of the family now. - She adopted a girl. [LINE] Speaker341: Welkom in de familie, Olivia! ~ Welcome in the family ~ $924/e25bf4a2.mp3;6,0;3,514;3,130;8,55;8,514 [LINE] Speaker856: Heel~erg~bedankt! ~ Thank~you~very~much $924/a0e6a5ee.mp3;4,50;4,375;8,225 [MULTIPLE_CHOICE] > After Odile was introduced to everyone... - ... Melissa's mother asked Olivia to leave. + ... Melissa's mother welcomed Olivia to her family. - ... she ran away quickly. [MATCH] > Tap the pairs. - the grandmother <> de oma - the daughter <> de dochter - the grandfather <> de opa - the brother <> de broer - the (girl)friend <> de vriendin ================================================ FILE: database/stories/nl-en/2_4_es-en-el-doctor-eddy.txt ================================================ [DATA] fromLanguageName=Doctor Eddy icon=29c5abbf74b46e43a4de510ac83e302c0722a100 set=2|4 approvals=11 [HEADER] > Dokter Eddy ~ doctor Eddy $5326/aa572766.mp3;6,50;5,500 [LINE] > Eddy is in de supermarkt ~ Eddy is at the supermarket $5326/afbb3d00.mp3;4,50;3,412;3,188;3,125;11,87 [LINE] > Een vrouw spreekt hem aan ~ a woman adresses him (to) $5326/b095c5ec.mp3;3,50;6,187;8,338;4,437;4,150 [MULTIPLE_CHOICE] > Eddy adresses a woman - Yes, that's right. + No, that's wrong. [LINE] Speaker724: Hallo meneer! ~ hello sir $5326/b5ceabe6.mp3;5,50;7,462 [LINE] Speaker414: Ja? ~ Yes? $5326/bcfcc6d2.mp3;2,50 [LINE] Speaker724: Bent u misschien een dokter? ~ are you by~any~chance a doctor $5326/66844b36.mp3;4,50;2,300;10,100;4,487;7,113 [LINE] Speaker414: Ik? Een dokter? uh... ~ me a doctor uh $5326/6b891ddc.mp3;2,50;4,737;7,213;3,925 [LINE] Speaker414: Ja! Hoe heeft u dat~gezien? ~ yes~I~am how did you see~that $5326/344514dc.mp3;2,50;4,862;6,263;2,300;4,62;7,200 [LINE] Speaker724: Gelukkig! ~ that's~good $5326/76a34eae.mp3;8,50 [LINE] > Maar Eddy is geen dokter. ~ However, Eddy is not~a doctor $5326/56f9b376.mp3;4,50;5,300;3,325;5,200;7,325 [ARRANGE] > Tap what you hear Speaker414: [(Ik~heb) (veel) (geld).] ~ I~have a~lot~of money $5326/7d45179c.mp3;2,50;4,187;5,163;5,300 [LINE] Speaker414: Ik woon in een heel groot huis ~ I live in a very big house $5326/2599e220.mp3;2,50;5,187;3,288;4,125;5,125;6,275;5,362 [LINE] Speaker414: Ik heb heel dure broeken ~ I have very expensive pants $5326/2a115dba.mp3;2,50;4,175;5,162;5,288;8,312 [LINE] Speaker724: Okee maar ~ OK but $5326/86a87554.mp3;4,50;5,450 [LINE] Speaker724: Die man heeft nu een dokter nodig ~ that man needs (now) a doctor~now (heeft~nodig=needs) $5326/8c7f1bb8.mp3;3,50;4,212;6,313;3,312;4,288;7,112;6,363 [LINE] Speaker724: Hij staat bij de melk ~ he is~(standing) near the milk $5326/92e9d66e.mp3;3,50;6,237;4,438;3,187;5,88 [LINE] Speaker724: Alstublíeft, deze man heeft~uw~hulp~nodig! ~ please that man needs~your~help $5326/96f09ed2.mp3;11,50;5,987;4,338;6,287;3,300;5,175;6,288 [MULTIPLE_CHOICE] > The man who needs help is near the… - …station. - …tomatoes. + …milk. [POINT_TO_PHRASE] > Choose the option that means "ill." Speaker724: (Hij~is) (erg) (+ziek)! ~ he~is very ill $5326/9c7066a8.mp3;3,50;3,287;4,238;5,250 [LINE] Speaker414: Oh, nee. ~ oh no $5326/a35a349e.mp3;2,50;4,550 [LINE] > Eddy ziet een vrouw, en spreekt haar aan ~ Eddy sees a woman and adresses her (spreekt~aan=addresses) $5326/de67126e.mp3;4,50;5,312;4,238;6,137;3,675;8,175;5,413;4,187 [LINE] Speaker414: Hallo mevrouw, bent u een dokter? ~ hello madam, are you a doctor $5326/95501886.mp3;5,50;8,400;5,775;2,287;4,88;7,125 [MULTIPLE_CHOICE] > What happened in the story? - Eddy started med school to become a doctor. + Eddy pretended to be a doctor to impress a woman. - Eddy saved a man at the supermarket. [MATCH] > Tap the pairs - the pair of trousers <> de broek - not a <> geen - sir <> meneer - a lot of <> veel - to address <> aanspreken ================================================ FILE: database/stories/test-en/1_1_es-en-buenos-dias.txt ================================================ [DATA] fromLanguageName=Testing icon=783305780a6dad8e0e4eb34109d948e6a5fc2c35 set=1|1 approvals=11,12 public=1 [HEADER] > This is the Title ~ Thes is the Title [LINE] > A narrator line. ~ A narrator line. [LINE] Speaker593: Hello, I am a speaker! ~ Hello, I am a speaker! [LINE] Speaker560: I am another speaker! ~ I am another speaker! [MULTIPLE_CHOICE] > Do you know how MULTIPLE_CHOICE works? + Right answers start with a + - negative answers with a - yes I~need to~go to work [ARRANGE] > Tap what you hear. The ARRANGE question uses () to create text buttons. Speaker593: [(First) (second) (third)] ~ First Second Third [POINT_TO_PHRASE] > Choose the option that means "right". In the POINT_TO_PHRASE Speaker560: The (+right) (word) has a plus (symbol). ~ sorry my love I~am tired I~work a~lot [SELECT_PHRASE] > Choose the best answer: The SELECT_PHRASE has similar answers. Speaker593: What is the right [word]? ~ What is the right word $56/ddacf426.mp3;3,50;3,225;4,125;9,100 + word - bird - skirt [CONTINUATION] > Tap what you hear. CONTINUATION Speaker560: I write with [words]. ~ I write with words. + words ~ words - sentences ~ sentences - texts ~ texts [MATCH] > Tap the pairs - A <> 1 - B <> 2 - C <> 3 - D <> 4 - E <> 5 ================================================ FILE: database/stories/test-en/1_2_es-en-una-cita.txt ================================================ [DATA] fromLanguageName=Minimal Example icon=df24f7756b139f6eda927eb776621b9febe1a3f1 set=1|2 approvals=11,12 public=1 [HEADER] > Title ~ Title [LINE] > The one and only text. ~ The one and only text. ================================================ FILE: discord_roles/CONTEXT.md ================================================ # Discord Roles This context describes Discord-side contributor onboarding and role language that should stay separate from the website product glossary. ## Language **Contributor Application**: A Discord-side request from a community member to become a contributor or contribute to another course. _Avoid_: Website application, course permission **Contributor**: A user who has been granted global permission to edit project content. _Avoid_: Course-scoped editor **Course Contributor**: A contributor credited for making a minimum contribution to a course. _Avoid_: Course permission ## Relationships - A **Contributor Application** is handled in Discord, not on the website. - A **Contributor** has global edit access rather than per-course edit permissions. - A **Course Contributor** is attribution, not authorization. ## Example dialogue > **Dev:** "Does approving a **Contributor Application** grant access only to one course?" > **Domain expert:** "No — approved contributors get global edit access, even if the application was about helping with a specific course." ## Flagged ambiguities - "application" usually refers to the Discord onboarding process, not an in-website workflow. - "course contributor" sounds like a permission role, but it is only contribution credit. ================================================ FILE: discord_roles/audio_cleanup.py ================================================ import mysql.connector import re import os from pathlib import Path import shutil mydb = mysql.connector.connect( host="localhost", user="carex", password="5hfW-18MSXgYvjrewhbP", database="carex_stories" ) def move(source, target): if not Path(target).parent.exists(): Path(target).parent.mkdir(parents=True) shutil.move(source, target) os.chdir("../..") shutil.move("audio", "audio_old") page = 10 offset = 0 while True: mycursor = mydb.cursor() mycursor.execute(f"SELECT id, course_id, text FROM story ORDER BY id LIMIT {page} OFFSET {offset}") myresult = mycursor.fetchall() offset += page if len(myresult) == 0: break for id, course_id, text in myresult: #print(id, course_id, text) #print(re.findall(r"\$(.*[^\/])\/([^\/]*\.mp3)", text)) print(re.findall(r"\$(.*\.mp3)", text)) for file in re.findall(r"\$(.*\.mp3)", text): move(Path("audio_old") / file, Path("audio") / file) ================================================ FILE: discord_roles/blame.py ================================================ import subprocess from pathlib import Path import pandas as pd import time import os def decode_git_stdout(result): # Some historical commit metadata is not valid UTF-8; decode replacement # keeps blame parsing working because we only consume author/content lines. return result.stdout.decode("utf-8", errors="replace") def get_commits_per_file(filename): a = subprocess.run(["git", "rev-list", "HEAD", "--oneline", filename], capture_output=True) out = a.stdout lines = [l.split(b" ", 1) for l in out.split(b"\n") if l.strip() != b''] return lines def get_author_percentages(filename, ignore_rev=None): print(filename) if ignore_rev is None: try: ignore_rev = get_commits_per_file(filename)[-1][0].decode() except IndexError: ignore_rev = None if ignore_rev: #print(subprocess.run(["git", "show", ignore_rev+":"+str(filename)], capture_output=True, text=True).stdout) base_file = [l.strip() for l in subprocess.run(["git", "show", ignore_rev+":"+str(filename)], capture_output=True, text=True).stdout.split("\n")] else: base_file = [] a = subprocess.run( ["git", "blame", "--line-porcelain", "-w", filename], capture_output=True, ) authors, count = parse_blame_porcelain(decode_git_stdout(a), base_file) #for author in authors: # authors[author] /= count print("---------", filename, authors) return authors, count def parse_blame_porcelain(output, base_file): authors = {} count = 0 current_author = None for line in output.splitlines(): if line.startswith("author "): current_author = line[len("author "):] continue if not line.startswith("\t"): continue line_content = line[1:].strip() if line_content == "" or not current_author: continue found = False for l in base_file: if line_content == l: found = True break if found: continue if current_author not in authors: authors[current_author] = 0 authors[current_author] += 1 count += 1 current_author = None return authors, count if 0: filename = "91/6920.txt" filename = "93/6170.txt" filename = "9/646.txt" filename = "129/4303.txt" filename = "132/4716.txt" get_commits_per_file(filename) get_author_percentages(filename) #get_author_percentages(filename, get_commits_per_file(filename)[-1][0]) exit() def get_files_since_commit(commit): print(" ".join(["git", "diff", "--name-only", commit, "HEAD"])) a = subprocess.run(["git", "diff", "--name-only", commit, "HEAD"], capture_output=True, text=True) print(a.stdout) files = [f for f in a.stdout.split("\n") if f != ''] return files def get_new_file_list(): try: print(Path("last_commit.txt").read_text()) new_files = get_files_since_commit(Path("last_commit.txt").read_text().strip()) except FileNotFoundError: new_files = Path(".").glob("**/*.txt") print(new_files) return new_files def update_repo(): os.chdir("../../") if not Path("unofficial-duolingo-stories-content").exists(): os.system("git clone https://github.com/rgerum/unofficial-duolingo-stories-content") os.chdir("unofficial-duolingo-stories-content") os.system("git pull") def update_output_csv(): start_time = time.time() update_repo() data_old = pd.read_csv("output.csv") data_old["story_id"] = [int(str(file).split("/")[1][:-4]) for file in data_old["filename"]] data = [] for file in get_new_file_list(): #for file in Path("99").glob("*.txt"): print(len(data_old)) data_old = data_old[data_old.filename != file] print(len(data_old)) authors, count = get_author_percentages(file) for author in authors: data.append(dict(author=author, filename=file, story_id=str(file).split("/")[1][:-4], percentage=authors[author]/count, lines=authors[author])) print(data) data = pd.DataFrame(data) data = pd.concat((data, data_old)) data = data.sort_values(["author", "percentage"], ascending=False) counter = 0 last_author = 0 def count(x): nonlocal counter, last_author #print(x) if x.author != last_author: last_author = x.author counter = 0 counter += 1 return counter data["number"] = data.apply(count, axis=1) os.system("git rev-parse HEAD > last_commit.txt") data.to_csv("output.csv", index=False) print(data) print(time.time() - start_time, "s") if __name__ == "__main__": update_output_csv() ================================================ FILE: discord_roles/combine.py ================================================ import json from pathlib import Path from urllib import error, request import pandas as pd from env_utils import load_env_file params = load_env_file(Path(__file__).parent / ".env.local") CONVEX_DISCORD_COMBINE_URL = params["CONVEX_DISCORD_COMBINE_URL"] DISCORD_ROLE_SYNC_SECRET = params["DISCORD_ROLE_SYNC_SECRET"] CACHE_DIR = Path(__file__).parent / ".cache" APPROVAL_CACHE_FILE = CACHE_DIR / "approvals_cache.csv" APPROVAL_CACHE_COLUMNS = ["approval_id", "legacy_user_id", "story_id", "date"] _contributor_users_cache = None _public_story_ids_cache = None def fetch_combine_resource(kind, *, cursor=None, num_items=200, since_date=None): payload = { "secret": DISCORD_ROLE_SYNC_SECRET, "kind": kind, "numItems": num_items, } if cursor is not None: payload["cursor"] = cursor if since_date is not None: payload["sinceDate"] = int(since_date) req = request.Request( CONVEX_DISCORD_COMBINE_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with request.urlopen(req, timeout=20) as resp: body = json.loads(resp.read().decode("utf-8")) except error.HTTPError as err: details = err.read().decode("utf-8") raise RuntimeError( f"convex combine data failed: HTTP {err.code}: {details}" ) from err except Exception as err: raise RuntimeError(f"convex combine data failed: {err}") from err if not isinstance(body, dict) or not body.get("ok"): raise RuntimeError(f"convex combine data returned invalid response: {body}") return body def fetch_contributor_users(): global _contributor_users_cache if isinstance(_contributor_users_cache, list): return _contributor_users_cache data = fetch_combine_resource("users") rows = data.get("users", []) users = [] for row in rows: if not isinstance(row, dict): continue legacy_user_id = row.get("legacyUserId") author = row.get("author") discord_account_id = row.get("discordAccountId") if not isinstance(legacy_user_id, int): continue if not isinstance(author, str): continue users.append( { "legacy_user_id": legacy_user_id, "author": author, "discord_account_id": discord_account_id if isinstance(discord_account_id, str) else None, } ) _contributor_users_cache = users return _contributor_users_cache def fetch_public_story_ids(): global _public_story_ids_cache if isinstance(_public_story_ids_cache, set): return _public_story_ids_cache story_ids = set() cursor = None while True: data = fetch_combine_resource("publicStories", cursor=cursor) for story_id in data.get("page", []): if isinstance(story_id, int): story_ids.add(story_id) if data.get("isDone"): break cursor = data.get("continueCursor") if not isinstance(cursor, str) or cursor == "": break _public_story_ids_cache = story_ids return _public_story_ids_cache def load_approval_cache(): CACHE_DIR.mkdir(exist_ok=True) if not APPROVAL_CACHE_FILE.exists(): return pd.DataFrame(columns=APPROVAL_CACHE_COLUMNS) data = pd.read_csv( APPROVAL_CACHE_FILE, dtype={ "approval_id": "string", "legacy_user_id": "Int64", "story_id": "Int64", "date": "Int64", }, ) for column in APPROVAL_CACHE_COLUMNS: if column not in data.columns: data[column] = pd.Series(dtype="object") return data[APPROVAL_CACHE_COLUMNS] def save_approval_cache(data): CACHE_DIR.mkdir(exist_ok=True) normalized = data.copy() if normalized.empty: normalized = pd.DataFrame(columns=APPROVAL_CACHE_COLUMNS) else: normalized = normalized.sort_values(["date", "approval_id"]).reset_index( drop=True ) normalized.to_csv(APPROVAL_CACHE_FILE, index=False) def update_approval_cache(): cache = load_approval_cache() existing_ids = set(cache["approval_id"].dropna().astype(str)) since_date = None if not cache.empty: since_date = int(cache["date"].dropna().max()) new_rows = [] cursor = None while True: data = fetch_combine_resource( "approvals", cursor=cursor, since_date=since_date, ) for row in data.get("page", []): if not isinstance(row, dict): continue approval_id = row.get("id") legacy_user_id = row.get("legacyUserId") story_id = row.get("storyId") date = row.get("date") if not isinstance(approval_id, str): continue if approval_id in existing_ids: continue if not isinstance(legacy_user_id, int) or not isinstance(story_id, int): continue if not isinstance(date, int): continue existing_ids.add(approval_id) new_rows.append( { "approval_id": approval_id, "legacy_user_id": legacy_user_id, "story_id": story_id, "date": date, } ) if data.get("isDone"): break cursor = data.get("continueCursor") if not isinstance(cursor, str) or cursor == "": break if new_rows: cache = pd.concat([cache, pd.DataFrame(new_rows)], ignore_index=True) save_approval_cache(cache) return cache def get_user_to_discord_mapping(): user_discord_id = {} for user in fetch_contributor_users(): if isinstance(user["discord_account_id"], str): user_discord_id[user["author"]] = user["discord_account_id"] return user_discord_id def get_user_approval_count(): contributor_users = fetch_contributor_users() author_by_legacy_user_id = { user["legacy_user_id"]: user["author"] for user in contributor_users } public_story_ids = fetch_public_story_ids() approvals = update_approval_cache() if approvals.empty: return pd.DataFrame(columns=["author", "story_id", "approval", "public"]) approvals = approvals.dropna( subset=["approval_id", "legacy_user_id", "story_id", "date"] ).copy() approvals["legacy_user_id"] = approvals["legacy_user_id"].astype(int) approvals["story_id"] = approvals["story_id"].astype(int) approvals["date"] = approvals["date"].astype(int) approvals["author"] = approvals["legacy_user_id"].map(author_by_legacy_user_id) approvals = approvals.dropna(subset=["author"]) approvals = approvals[approvals["story_id"].isin(public_story_ids)] approvals = approvals.sort_values(["story_id", "date", "approval_id"]) # The cache is append-only, so revokes/re-approvals can duplicate a user/story # pair over time. Keep the earliest approval we have seen for milestone credit. approvals = approvals.drop_duplicates( subset=["story_id", "legacy_user_id"], keep="first", ) approvals["approval_rank"] = approvals.groupby("story_id").cumcount() + 1 approvals = approvals[approvals["approval_rank"] <= 2] approvals["approval"] = 1 approvals["public"] = 1 return approvals[["author", "story_id", "approval", "public"]] def join_and_group_data(): from blame import update_output_csv update_output_csv() data = pd.read_csv("output.csv") data["story_id"] = [int(str(file).split("/")[1][:-4]) for file in data["filename"]] data0 = data data = data[data.percentage >= 0.1] data = data[data.lines >= 3] data = pd.concat([get_user_approval_count(), data]) data = data.sort_values("story_id", ascending=False) data = data.groupby(["story_id", "author"]).max( ["number", "story_id", "percentage", "lines"] ) data = data.reset_index().sort_values("story_id", ascending=False) data = data[data.public == 1] print(data) data.to_csv("joined.csv") author_story_counts = ( data.groupby("author") .agg(story_count=("story_id", "count")) .sort_values("story_count", ascending=False) ) return author_story_counts, data0 def get_milestone_grouped(): user_roles = [] for row in get_stories_role_sync_rows(): if row["milestone_stories"] is None or not row["discord_account_id"]: continue user_roles.append([row["discord_account_id"], row["milestone_stories"]]) return user_roles def get_stories_role_sync_rows(): data, data0 = join_and_group_data() milestones = [4, 8, 20, 40, 80, 120] milestone_by_author = {} for author, row in data.iterrows(): story_count = int(row.story_count) milestone = None for candidate in milestones[::-1]: if story_count >= candidate: milestone = candidate break milestone_by_author[author] = milestone rows = [] for user in fetch_contributor_users(): milestone = milestone_by_author.get(user["author"]) rows.append( { "legacy_user_id": user["legacy_user_id"], "author": user["author"], "discord_account_id": user["discord_account_id"], "milestone_stories": milestone, } ) return rows def get_milestone_grouped_debug_missing_links(): data, data0 = join_and_group_data() milestones = [4, 8, 20, 40, 80, 120] user_discord_id = get_user_to_discord_mapping() user_roles = [] for mile in milestones[::-1]: print("----", mile, "stories", "----") d = data[data.story_count >= mile] for i, author in d.iterrows(): print( i, author.story_count, f"({len(data0[data0.author == i])})", user_discord_id.get(i, "none"), ) if user_discord_id.get(i, None): user_roles.append([user_discord_id.get(i), mile]) data = data[data.story_count < mile] print(user_roles) return user_roles def get_milestone_grouped2(): data, data0 = join_and_group_data() milestones = [4, 8, 20, 40, 80, 120] user_discord_id = get_user_to_discord_mapping() user_roles = [] for mile in milestones[::-1]: print("----", mile, "stories", "----") d = data[data.story_count >= mile] for i, author in d.iterrows(): print( i, author.story_count, f"({len(data0[data0.author == i])})", user_discord_id.get(i, "none"), ) if not user_discord_id.get(i, None): user_roles.append([i, mile]) data = data[data.story_count < mile] print(user_roles) return user_roles if __name__ == "__main__": get_milestone_grouped() ================================================ FILE: discord_roles/discord_bot.py ================================================ import asyncio import discord import json import time from urllib import error, request from pathlib import Path from env_utils import load_env_file params = Path(__file__).parent / ".env.local" params = load_env_file(params) CHANNEL_BOT_LOG = 1133529323396145172 CONVEX_DISCORD_STORIES_ROLE_STATUS_URL = params.get( "CONVEX_DISCORD_STORIES_ROLE_STATUS_URL", params["CONVEX_DISCORD_SYNC_URL"].replace( "/set-contributor-write", "/set-stories-role-status", ), ) CONVEX_DISCORD_SYNC_SECRET = params["DISCORD_ROLE_SYNC_SECRET"] def sync_stories_role_status(snapshots): payload = { "secret": CONVEX_DISCORD_SYNC_SECRET, "snapshots": snapshots, } req = request.Request( CONVEX_DISCORD_STORIES_ROLE_STATUS_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with request.urlopen(req, timeout=20) as resp: body = json.loads(resp.read().decode("utf-8")) except error.HTTPError as err: details = err.read().decode("utf-8") raise RuntimeError( f"convex stories role sync failed: HTTP {err.code}: {details}" ) from err except Exception as err: raise RuntimeError(f"convex stories role sync failed: {err}") from err if not isinstance(body, dict) or not body.get("ok"): raise RuntimeError( f"convex stories role sync returned invalid response: {body}" ) return body def get_snapshot_row(row, *, sync_status, assigned_stories_count=None, last_error=None): milestone_stories = row.get("milestone_stories") return { "legacyUserId": int(row["legacy_user_id"]), "discordAccountId": row.get("discord_account_id"), "eligibleStoriesCount": int(milestone_stories) if isinstance(milestone_stories, int) else None, "assignedStoriesCount": assigned_stories_count, "syncStatus": sync_status, "lastSyncedAt": int(time.time() * 1000), "lastError": last_error, } def set_user_roles(sync_rows): try: global params # Token of your Discord bot TOKEN = params['DISCORD_TOKEN'] # ID of your server GUILD_ID = 726701782075572277 # ID of the role you want to assign ROLE_ID = { 200: 1129006031868002325, 120: 1021418996500799518, 80: 1021418781853098005, 40: 1021418701158875269, 20: 1021418386334416978, 8: 1021417953956208650, 4: 1021423243627864094, } # ID of the user you want to assign the role to USER_ID = 724679808071761982 # Create a bot instance intents = discord.Intents.default() intents.typing = False intents.presences = False intents.members = False # Enable the Members intent bot = discord.Client(intents=intents) async def log(message): channel = bot.get_channel(CHANNEL_BOT_LOG) await channel.send(message) # Event triggered when the bot is ready @bot.event async def on_ready(): print(f'Logged in as {bot.user.name}') # Fetch the server (guild) from its ID guild = bot.get_guild(GUILD_ID) if guild is None: print('Guild not found') await bot.close() return # Fetch the role from its ID roles = {k: guild.get_role(v) for k, v in ROLE_ID.items()} snapshots = [] # Fetch the member from their ID print(guild.name) for row in sync_rows: discord_account_id = row.get("discord_account_id") target_count = row.get("milestone_stories") print("USER_ID", discord_account_id) if not discord_account_id: snapshots.append( get_snapshot_row(row, sync_status="not_linked") ) continue try: member = await guild.fetch_member(int(discord_account_id)) except discord.errors.NotFound: print("NOT FOUND") snapshots.append( get_snapshot_row( row, sync_status="member_not_found", ) ) continue if member is None: print('Member not found') snapshots.append( get_snapshot_row( row, sync_status="member_not_found", ) ) continue print(member.roles) role_max = max([0]+[int(role.name[:-len(" Stories")]) for role in member.roles if "Stories" in role.name]) should_assign = isinstance(target_count, int) and role_max < target_count print("max", role_max, should_assign, target_count, member.roles) try: if should_assign: await member.add_roles(roles[target_count]) await member.remove_roles( *[role for k, role in roles.items() if k != target_count] ) await log(f"🏅 I gave {member.name} the role {roles[target_count].name}. Previous role '{role_max} Stories'") print(f'Role {roles[target_count].name} added to {member.name}') print(f'Roles {[role.name for k, role in roles.items() if k != target_count]} removed from {member.name}') snapshots.append( get_snapshot_row( row, sync_status="assigned", assigned_stories_count=target_count, ) ) else: snapshots.append( get_snapshot_row( row, sync_status="up_to_date" if isinstance(target_count, int) else "no_milestone", assigned_stories_count=role_max or None, ) ) except Exception as err: print(err) snapshots.append( get_snapshot_row( row, sync_status="error", assigned_stories_count=role_max or None, last_error=str(err), ) ) if snapshots: await asyncio.to_thread(sync_stories_role_status, snapshots) await bot.close() # Start the bot bot.run(TOKEN) exit() except: return if __name__ == "__main__": from combine import get_stories_role_sync_rows sync_rows = get_stories_role_sync_rows() set_user_roles(sync_rows) ================================================ FILE: discord_roles/discord_reacting_bot.py ================================================ import discord import json from urllib import error, request from pathlib import Path from env_utils import load_env_file def sync_user_role(discord_id, write=None): payload = { "secret": CONVEX_DISCORD_SYNC_SECRET, "discordAccountId": str(discord_id), "write": write if write is None else bool(write), } req = request.Request( CONVEX_DISCORD_SYNC_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with request.urlopen(req, timeout=10) as resp: body = json.loads(resp.read().decode("utf-8")) return body except error.HTTPError as err: details = err.read().decode("utf-8") raise RuntimeError(f"convex sync failed: HTTP {err.code}: {details}") from err except Exception as err: raise RuntimeError(f"convex sync failed: {err}") from err # Replace 'YOUR_BOT_TOKEN' with your actual bot token obtained from the Discord Developer Portal. params = Path(__file__).parent / ".env.local" params = load_env_file(params) TOKEN = params['DISCORD_TOKEN'] CONVEX_DISCORD_SYNC_URL = params['CONVEX_DISCORD_SYNC_URL'] CONVEX_DISCORD_SYNC_SECRET = params['DISCORD_ROLE_SYNC_SECRET'] CHANNEL_CONTRIBUTOR_REQUEST = 1132747276234792980 #CHANNEL_CONTRIBUTOR_REQUEST = 1133167220109877280 # test channel CHANNEL_BOT_LOG = 1133529323396145172 ROLE_MODERATOR = 735581436903424120 ROLE_CONTRIBUTOR = 941815741143977994 class MyClient(discord.Client): async def on_ready(self): print(f'Logged on as {self.user}!') async def on_message(self, message): if message.author == client.user: return # Ignore messages sent by the bot itself # for the contributor request channel if getattr(message.channel, "parent", None) and message.channel.parent.id == CHANNEL_CONTRIBUTOR_REQUEST: channel = message.channel # get the applicants message first_message = await channel.fetch_message(channel.id) # check if they are connected try: result = sync_user_role(first_message.author.id, None) if result.get("linked"): await first_message.add_reaction('🔗') await first_message.remove_reaction('✖️', client.user) await first_message.remove_reaction('❌', client.user) else: await first_message.add_reaction('❌') await first_message.remove_reaction('🔗', client.user) if message.id == first_message.id: await message.channel.send("Please connect your Duostories account to your Discord account (on ). Then post another message here and I will check again.") except Exception as err: print(err) await self.log(f"⚠️ could not check Duostories account linkage for {first_message.author.name}, a database error occurred.\n```{err}```") def _is_contributor_request_channel(self, channel): """Check if the channel is a thread in the contributor request forum.""" try: return channel.parent.id == CHANNEL_CONTRIBUTOR_REQUEST except AttributeError: return False async def _get_first_message_if_match(self, channel, message_id): """Return the first message in the thread if message_id matches it, else None. A thread's ID equals its starter message's ID, so we can skip the API call when they don't match.""" if message_id != channel.id: return None return await channel.fetch_message(channel.id) async def check_reaction(self, reaction): # reaction.member is None for reaction remove events if reaction.member is None: return None # Check if the reacting user is a moderator is_moderator = discord.utils.get(reaction.member.roles, id=ROLE_MODERATOR) if reaction.member == client.user: return # Ignore reactions on the bot's own messages if is_moderator and reaction.emoji.name == '✅': # Get the channel where the reaction occurred channel = client.get_channel(reaction.channel_id) if not self._is_contributor_request_channel(channel): return None return await self._get_first_message_if_match(channel, reaction.message_id) return None async def on_raw_reaction_add(self, reaction): if message := await self.check_reaction(reaction): await message.add_reaction('✅') # React to the moderator's reaction with a thumbs-up emoji user = message.author guild = client.get_guild(reaction.guild_id) user_member = await guild.fetch_member(user.id) role_to_give = discord.utils.get(guild.roles, id=ROLE_CONTRIBUTOR) if user_member and role_to_give: await user_member.add_roles(role_to_give) await self.log(f"🧑‍💻️ I gave {user.name} the role {role_to_give.name}.") print(f"Gave {user.name} the role: {role_to_give.name}") try: result = sync_user_role(user.id, True) user_data = result.get("user") if isinstance(result, dict) else None if result.get("linked") and user_data: role_name = user_data.get("role", "unknown") user_id = user_data.get("id") username = user_data.get("name", "") await self.log(f"📝 added write permissions for {user.name}. Duostories id={user_id} username={username} role={role_name} ") await message.channel.send("I gave you the **Contributor** role and activated your account on Duostories.\nIf you are currently logged in on , please log out and in again for the changes to take effect.\nYou can then access the editor at .") else: await message.channel.send("I gave you the **Contributor** role but I could not activate your account on Duostories because you haven't connected your Duostories account to Discord.") except Exception as err: print(err) await self.log(f"⚠️ could not add write permissions for {user.name}, a database error occurred.\n```{err}```") await message.channel.send("I gave you the **Contributor** role, but I could not activate your account on Duostories because a database error occurred.") async def on_raw_reaction_remove(self, reaction): if reaction.emoji.name != '✅': return channel = client.get_channel(reaction.channel_id) if not self._is_contributor_request_channel(channel): return # reaction.member is None for remove events; fetch to verify moderator status guild = client.get_guild(reaction.guild_id) if guild is None: return try: member = await guild.fetch_member(reaction.user_id) except discord.NotFound: return if not discord.utils.get(member.roles, id=ROLE_MODERATOR): return message = await self._get_first_message_if_match(channel, reaction.message_id) if message: await message.remove_reaction('✅', client.user) async def on_member_update(self, before, after): # Check if roles have been added or removed roles_added = set(after.roles) - set(before.roles) roles_removed = set(before.roles) - set(after.roles) if roles_added: for role in roles_added: if role.id == ROLE_CONTRIBUTOR: print("update database") try: result = sync_user_role(after.id, True) user_data = result.get("user") if isinstance(result, dict) else None if result.get("linked") and user_data and result.get("updated"): role_name = user_data.get("role", "unknown") user_id = user_data.get("id") username = user_data.get("name", "") await self.log(f"📝 added write permissions for {after.name}. Duostories id={user_id} username={username} role={role_name} ") elif not result.get("linked"): await self.log(f"⚠️ could not add write permissions for {after.name}, account is not linked to duostories.") except Exception as err: print(err) await self.log(f"⚠️ could not add write permissions for {after.name}, a database error occurred.\n```{err}```") print(f"User {after.name} has been given the role: {role.name}") # Add your reaction logic here for when roles are added to a user. # For example, you could send a message or give another role. if roles_removed: for role in roles_removed: if role.id == ROLE_CONTRIBUTOR: print("update database") try: result = sync_user_role(after.id, False) user_data = result.get("user") if isinstance(result, dict) else None if result.get("linked") and user_data: role_name = user_data.get("role", "unknown") user_id = user_data.get("id") username = user_data.get("name", "") await self.log(f"❌ removed write permissions for {after.name}. Duostories id={user_id} username={username} role={role_name} ") else: await self.log(f"⚠️ could not remove write permissions for {after.name}, account is not linked to duostories.") except Exception as err: print(err) await self.log(f"⚠️ could not remove write permissions for {after.name}, a database error occurred.\n```{err}```") print(f"User {after.name} has lost the role: {role.name}") # Add your reaction logic here for when roles are removed from a user. # For example, you could send a message or remove another role. async def log(self, message): channel = self.get_channel(CHANNEL_BOT_LOG) await channel.send(message) intents = discord.Intents.default() intents.message_content = True intents.reactions = True intents.members = True client = MyClient(intents=intents) # Run the bot with the specified token client.run(TOKEN) ================================================ FILE: discord_roles/env_utils.py ================================================ from pathlib import Path def load_env_file(path: Path) -> dict[str, str]: env: dict[str, str] = {} for raw_line in path.read_text().splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if not key: continue if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: value = value[1:-1] env[key] = value return env ================================================ FILE: discord_roles/requirements.txt ================================================ mysql-connector-python pandas numpy discord ================================================ FILE: docs/bulk-audio-editor-spec.md ================================================ # Bulk Audio Editor Spec ## Goal Create a dedicated story-level audio workspace that lets editors upload many audio files, review line-to-file matching, set word timing markers quickly, test playback, and apply all changes back to the story editor in one pass. ## Why This Exists The current audio workflow is line-by-line: - open one line - upload one file - adjust timings - save - move to the next line That is too click-heavy for story-wide recording passes. Bulk audio editing should keep the editor in a single focused workspace. ## Constraints - Story audio is still stored inline in the story text as `$filename;timings`. - Each audio-capable story element is mapped through `audio.ssml.inser_index`. - Replacing many audio lines must avoid line-number drift while editing the document. - Existing single-line audio editing stays available for cleanup and edge cases. ## V1 Scope ### Entry Point - Add a `Bulk Audio` action to the story editor header. - Open a dedicated modal instead of reusing the single-line overlay. ### Data Model The bulk editor works from parsed story elements and only includes audio-capable items: - header audio - line audio Each row carries: - display order - story `line_index` - speaker - source text - existing filename - existing keypoints - SSML insertion metadata ### Layout Two-pane modal: - Left pane: scrollable queue of audio rows with status and file assignment. - Right pane: active row editor with audio player, timing tools, and token list. ### File Workflow - Accept many files via drag-and-drop or file picker. - Auto-match by leading filename number when possible. - Fallback to assignment by row order for unmatched files. - Allow replacing the file for the active row manually. - Keep unmatched files visible so the user can resolve them. ### Timing Workflow - Show tokenized story text for the active row. - Let the user assign the current playback position to the selected token. - Allow clearing timings and removing the last marker. - Preserve existing timings until the user changes them. ### Status Model Rows surface these states: - missing - staged - uploaded - timed - failed Top-level summary shows counts for: - total rows - ready rows - timed rows - missing rows ### Apply Flow - Upload any newly staged local files. - Convert timing markers into keypoints. - Serialize each changed row back into `$filename;timings`. - Apply all row edits safely to the CodeMirror document. ## V1 Non-Goals - Automatic forced alignment - Story-level waveform stitching - Audio deletion and blob cleanup - Server-side batch upload endpoint - Cross-session draft persistence ## Follow-Up Ideas - Auto-advance while marking timings during playback - Keyboard-first transport and marking shortcuts - Smarter filename matching with speaker/text hints - Even-spacing starter markers for untimed files - Batch retry and resumable draft state ================================================ FILE: import_tools/README.md ================================================ # Import Tools This folder contains tools to import the stories from the Duolingo website. ================================================ FILE: import_tools/app.py ================================================ from flask import Flask from flask import Flask, render_template, send_from_directory import os from flask import request app = Flask(__name__) @app.route("/") def hello_world(): return "

Hello, World!

" root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "whereyourfilesare") from pathlib import Path @app.route('/login', methods=['GET']) def login(): print(request.path) p = Path(request.args.get('key', '')) print(p) return send_from_directory(p.parent, p.name) @app.route('/store', methods=['GET', 'POST']) def getfiles(): import re import tifffile filename = request.args.get('id', '') filename = "duolingo_data/"+filename+".txt" print(filename) json = request.form['json'] with open(filename, "w") as fp: fp.write(json) return "done " + filename ================================================ FILE: import_tools/greasmonkey.js ================================================ // ==UserScript== // @name DuolingoImport // @version 1 // @include https*duolingo* // @grant none // ==/UserScript== function fetch_post(url, data) { /** like fetch but with post instead of get */ var fd = new FormData(); //very simply, doesn't handle complete objects for (var i in data) { fd.append(i, data[i]); } var req = new Request(url, { method: "POST", body: fd, mode: "cors", }); return fetch(req); } console.log("grease monkey 2"); async function getStories(learningLanguage, fromLanguage) { console.log("grease monkey 2xxx"); data = await fetch( `https://stories.duolingo.com/api2/stories?crowns=163&filterMature=false&fromLanguage=${fromLanguage}&illustrationFormat=svg&learningLanguage=${learningLanguage}&masterVersions=false&proposed=false&setSize=4&unlockingMechanism=crowns&_=1636940908268`, ); json = await data.json(); console.log("json", json); data = await fetch_post(`http://127.0.0.1:5000/store?id=_stories`, { json: JSON.stringify(json, null, 2), }); txt = await data.text(); console.log("response", txt); for (set_index in json.sets) { let set = json.sets[set_index]; if (set < 15) continue; for (story_index in set) { let story = set[story_index]; console.log(set_index, story_index, story.id); data = await fetch( `https://stories.duolingo.com/api2/stories/${story.id}?crowns=173&debugShowAllChallenges=false&illustrationFormat=svg&isDesktop=true&masterVersion=false&mode=read&supportedElements=ARRANGE,CHALLENGE_PROMPT,DUO_POPUP,FREEFORM_WRITING,FREEFORM_WRITING_EXAMPLE_RESPONSE,FREEFORM_WRITING_PROMPT,HEADER,HINT_ONBOARDING,LINE,MATCH,MULTIPLE_CHOICE,POINT_TO_PHRASE,SELECT_PHRASE,SUBHEADING,TYPE_TEXT&_=1640882394614`, ); let json2 = await data.json(); console.log("json2", json2); data = await fetch_post(`http://127.0.0.1:5000/store?id=${story.id}`, { json: JSON.stringify(json2, null, 2), }); txt = await data.text(); console.log("response", txt); //break } //break } //console.log(json.sets[0][0].id); } getStories("es", "en"); getStories("fr", "en"); window.getStories = getStories; document.getStories = getStories; //https://stories.duolingo.com/api2/stories/es-en-buenos-dias?crowns=163&debugShowAllChallenges=false&illustrationFormat=svg&isDesktop=true&masterVersion=false&mode=read&supportedElements=ARRANGE,CHALLENGE_PROMPT,FREEFORM_WRITING,HEADER,HINT_ONBOARDING,LINE,MATCH,MULTIPLE_CHOICE,POINT_TO_PHRASE,SELECT_PHRASE,SUBHEADING,TYPE_TEXT&_=1636940601358 ================================================ FILE: instrumentation-client.ts ================================================ import posthog from "posthog-js"; const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; if (posthogKey) { posthog.init(posthogKey, { api_host: "/ingest", ui_host: "https://us.posthog.com", // Include the defaults option as required by PostHog defaults: "2025-11-30", // Enables capturing unhandled exceptions via Error Tracking capture_exceptions: true, // Turn on debug in development mode debug: process.env.NODE_ENV === "development", }); } ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } } ================================================ FILE: knip.json ================================================ { "$schema": "https://unpkg.com/knip@latest/schema.json", "entry": [ "scripts/backfill-course-contributors.ts", "scripts/backfill-discord-avatars.ts", "scripts/find-missing-story-images.ts", "convex/convex.config.ts", "convex/betterAuth/convex.config.ts" ], "ignore": [ ".storybook/**", "convex/betterAuth/_generated/**", "convex/betterAuth/adapter.ts", "database/**", "import_tools/**", "public/docs/**", "public/sw.js", "public/darklight.js" ], "ignoreBinaries": [ "next" ], "ignoreDependencies": [ "react-dom" ], "ignoreIssues": { "src/components/editor/story/parser.ts": [ "exports" ], "convex/betterAuth/auth.ts": [ "exports" ] } } ================================================ FILE: next.config.js ================================================ module.exports = { // next.js config reactCompiler: true, compiler: { styledComponents: true, }, typescript: { ignoreBuildErrors: true, }, // PostHog reverse proxy to avoid ad blockers async rewrites() { return [ { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", }, { source: "/ingest/:path*", destination: "https://us.i.posthog.com/:path*", }, ]; }, // Required for PostHog trailing slash API requests skipTrailingSlashRedirect: true, images: { remotePatterns: [ { protocol: "https", hostname: "opencollective.com", port: "", pathname: "/duostories/contribute/**", }, { protocol: "https", hostname: "stories-cdn.duolingo.com", port: "", pathname: "/image/**", }, ], }, }; ================================================ FILE: package.json ================================================ { "private": true, "scripts": { "dev": "next", "build": "next build", "start": "next start", "test": "pnpm exec tsx --test src/**/*.test.ts", "format": "pnpm exec biome format src convex --write", "format:check": "pnpm exec biome format src convex", "lint": "pnpm run format:check && pnpm exec biome lint src convex", "typecheck": "tsc --noEmit", "knip": "pnpm dlx knip --production", "optimize:flags": "pnpm dlx svgo -f flags --multipass", "audit:missing-story-images": "pnpm exec tsx scripts/find-missing-story-images.ts", "backfill:discord-avatars": "pnpm exec tsx scripts/backfill-discord-avatars.ts", "backfill:course-contributors": "pnpm exec tsx scripts/backfill-course-contributors.ts" }, "dependencies": { "@aws-sdk/client-polly": "^3.1038.0", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@convex-dev/better-auth": "^0.12.0", "@lezer/highlight": "^1.2.3", "@mdx-js/mdx": "^3.1.1", "@octokit/rest": "^22.0.1", "@radix-ui/react-dialog": "^1.1.15", "@tanstack/react-virtual": "^3.13.24", "@vercel/blob": "^2.3.3", "@wavesurfer/react": "^1.0.12", "base64-arraybuffer": "^1.0.2", "better-auth": "1.6.9", "clsx": "^2.1.1", "codemirror": "^6.0.2", "convex": "^1.36.1", "fflate": "^0.8.2", "framer-motion": "^12.38.0", "immer": "^11.1.4", "js-md5": "^0.8.3", "lamejs": "^1.2.1", "lucide-react": "^1.12.0", "microsoft-cognitiveservices-speech-sdk": "^1.49.0", "next": "^16.2.6", "next-mdx-remote": "^6.0.0", "posthog-js": "^1.372.3", "posthog-node": "^5.30.6", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", "react-swipeable": "^7.0.2", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.5.0", "uuid": "^14.0.0", "vfile": "^6.0.3", "wavesurfer.js": "^7.12.6", "ws": "^8.20.0", "yaml": "^2.8.3", "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "2.4.13", "@tailwindcss/postcss": "^4.2.4", "@types/hast": "^3.0.4", "@types/mdx": "^2.0.13", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "^1.0.0", "dotenv": "^17.4.2", "shadcn": "^4.6.0", "tailwindcss": "^4.2.4", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3" } } ================================================ FILE: postcss.config.mjs ================================================ const config = { plugins: { "@tailwindcss/postcss": {}, }, }; export default config; ================================================ FILE: process.d.ts ================================================ declare module "*.svg" { const src: string; export default src; } declare module "ws"; ================================================ FILE: public/.well-known/assetlinks.json ================================================ [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "org.duostories.twa", "sha256_cert_fingerprints": ["AA:AA:9B:32:C3:F9:AF:1E:11:E6:E3:9E:CC:95:4A:F5:8D:84:E5:A7:BE:C3:7D:EF:2A:1B:4C:96:2B:51:BC:6C", "CD:32:C5:69:5C:21:52:6B:0A:0A:F9:BB:D9:23:F6:FF:01:83:44:22:9A:49:E4:B9:CE:BD:C6:9B:89:45:CA:7F"] } } ] ================================================ FILE: public/darklight.js ================================================ function get_current_theme() { // it's currently saved in the document? if (document.body.dataset.theme) { return document.body.dataset.theme; } // it has been previously saved in the window? if ( window.localStorage.getItem("theme") !== undefined && window.localStorage.getItem("theme") !== "undefined" ) { return window.localStorage.getItem("theme"); } // or the user has a preference? if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark"; return "light"; } console.log("activeTheme..."); function load() { let activeTheme = get_current_theme(); console.log("activeTheme", activeTheme); document.body.dataset.theme = activeTheme; window.localStorage.setItem("theme", activeTheme); } load(); document.addEventListener("DOMContentLoaded", load); ================================================ FILE: public/docs/audio-generation/character-editor.mdx ================================================ --- title: "Character Editor" description: "Assign voices to characters." --- In the character editor you can assign voices to the characters that appear in the stories. ### Character Names You can also assign a name to the character. The name is just for reference if the character is addressed in a story to always use the same spelling. The characters in the first row are the basic Duolingo cast. We want to keep their names as close to the original as possible. Just adjust them to the spelling of the target language. ![First row of the Character editor](/docs/audio-generation/base_cast.png "Base Characters") For example, if your language does not have a "v" and uses a "w" instead, you are able to change Vikram's name to "Wikram" ![How Vikram can be changed to Wikram](/docs/audio-generation/vikram.png "Vikram -> Wikram") As for these ones, feel free to change them to whatever you want to fit your language! For example, If you're making Finnish stories, use Finnish names. It will help the stories seem more realistic. ![List of the side characters](/docs/audio-generation/other_cast.png "The side characters") ### Vocal Modifiers You can add tags on to the end of your TTS to modify it: the rate at which the character speaks and the pitch at which they speak. For example, to make Junior sound more like a child, I can make him speak faster and higher by adding tags, so I put it-IT-ElsaNeural(pitch=x-high)(rate=fast) in the Speaker section. Pitch has 5 levels: `x-low`, `low`, `medium`, `high`, `x-high` Rate has 5 levels: `x-slow`, `slow`, `medium`, `fast`, `x-fast` Feel free to use these, or not. ![Display of the sliders for pitch and speed](/docs/audio-generation/pitch_speed.png "The sliders for pitch and speed") ================================================ FILE: public/docs/audio-generation/edit.mdx ================================================ --- title: "TTS Edit" description: "Change the audio generation with replacement rules." --- Sometimes the generated audio does not fit how you want the word or sentence to sound. Or maybe you want to use a voice form a different language if your language does not have voices. Therefore, you can adjust the text that is send to the Speech Engine. In the TTS Editor you can define rules to change the text before it is send to the TTS service. There are three types of rules. Letter replacements, Fragment replacements and Word replacements. To open it click on the Edit button in the top right corner of the character editor: ![The button to open the TTS editor.](/docs/audio-generation/tts_edit_button.png "Opens the TTS editor.") ### Letter Replacements Letter replacements are defined by a list of letters followed by a colon and a replacement string. They are always replaced in the given order. On the left side of the colon there can only be one letter. ``` LETTERS: o: u e: i ``` ### Fragment Replacements Fragment replacements can be used to change syllables in a word. If the fragment ends in \b it only replaces words that end in the fragment. If the fragment starts with \b the fragment has to be at the beginning of the word. ``` FRAGMENTS: ion\b: flug sem: dem ``` ### Word replacements To replace whole words. A word will only be replaced with it is surrounded by white space or punctuation characters, e.g. not if it is found within another word. ``` WORDS: oh: uuuh Worcester: WOO-STER ``` If you want to provide the word in the [International Phonetic Alphabet (IPA)](https://en.wikipedia.org/wiki/International_Phonetic_Alphabet) instead, you can write after the word `:ipa`. ================================================ FILE: public/docs/audio-generation/engines.mdx ================================================ --- title: "Speech Engines" description: "There are different text-to-speech (TTS) engines that Duostories can use." --- Duostories uses different services for text to speech generation. Depending on which voice name you select a different engine will be uses. ### Azure TTS The voices have the format `en-GB-SoniaNeural`. You can get a list of voices: [here](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts). ### Google TTS The voices have the format `tr-TR-Wavenet-A` or `tr-TR-Standard-A`. The voices with "Wavenet" use an AI method for voice generation and produce typically a better output.Duostories You can get a list of voices: [here](https://cloud.google.com/text-to-speech/docs/voices) ### Amazon Polly These voices have a format `Justin`. You can ge a list of voices: [here](https://docs.aws.amazon.com/polly/latest/dg/voicelist.html) ================================================ FILE: public/docs/audio-generation/fix-problems.mdx ================================================ --- title: "Fix Problems" description: "What do I do if the TTS is pronouncing words incorrectly?" --- ### Use another voice/service Some TTS voices/services might be better in your language than others. If you are fortunate to have multiple voices to choose from, you might be able to just avoid using the ones that don't work well. ### Trick the TTS system You can "trick" the TTS by using any text you want when you generate the audio. After you create the audio, you can change the text back to be written correctly. You might need to experiment with a few different ways of writing the text until the pronunciation sounds correct and natural. Tip: keep track of when you do this with #comment lines, so that if you need to edit anything in the line later, it will be easy to copy-paste the "trick" text in-and-out to create new audio. Ex: ![The import button](/docs/audio-generation/replace.png "Keeping track of replaced words") ### Curly Braces Method You can use the "curly braces" method. If you write that section of text as `show~word{speak~word}`, the TTS will show the first part (which is spelled/written correctly), and speak the part in the curly braces (spelled/written however you need to get the correct pronunciation). Caution: there is a known bug that this method stops the audio generator from adding the time markers for the "read along" highlighting of the text. You might only have highlighting for part of your line, or no highlighting at all. ================================================ FILE: public/docs/audio-generation/generate.mdx ================================================ --- title: "Generate Audio" description: "How to apply the voices in a story." --- In the story, click the Audio button in the header. ![Location of the audio button in the story editor.](/docs/audio-generation/audio_button.png "The button to display audio generation.") Then, for every line, click the spinner next to it. It is recommend waiting for each line to generate before moving to the next one, there's currently a small timing issue in it. ![The spinner icon next to the line.](/docs/audio-generation/spinner.png "The creation of audio.") If you generate them too quickly, the audio line may end up getting misplaced, like this. To fix it, just cut the hint line, and paste it above the yellow one. ![The line is created in the wrong position.](/docs/audio-generation/error.png "An error that could occur.") ### The audio line The line that is inserted after the audio has been created show the location of the audio file and the numbers for the word timings. You generally do not need to edit it. ================================================ FILE: public/docs/audio-generation/overview.mdx ================================================ --- title: "Overview" description: "The basic steps to generate audio." --- To make the Duostories more engaging we use audio for the text in the stories. This audio is machine generated using text-to-speech (TTS) engines. If you have translated a story and want to add audio, you first need to assign voices to the characters in the story using the "Character Editor" if they do not already have voices. Then you can generate each audio line separately, see "Generate Audio". ================================================ FILE: public/docs/become-contributor/application.mdx ================================================ --- title: "Application" description: "Join our community as a contributor." --- If you would like to contribute by translating stories into other languages: ### Requirements To contribute stories, you must: - Be a native speaker of the language, - Have native-level proficiency in the language, or - Have a native-speaker "partner" who can revise your work. If you plan on working with a native-speaker partner, it is best to have that native speaker join this server and submit an application as well, so that you both can be given access to the Editor. The only exceptions are for those languages that have no (or very few) native speakers including dead languages, nearly-extinct languages, and conlangs. Please, only apply to be a contributor if you’re willing to translate at least one Set of (4) Stories. If you are applying to translate stories into a dialect, regionally-specific language, conlang, or auxlang: please see our policies related to those, and include in your application any information about the language you would like us to consider. ### Application Process - Create an account on [Duostories.org](https://duostories.org/), if you don't already have one. This account will be used for accessing the Editor and Stories. - On your [profile page](https://duostories.org/profile), link your Duostories account to this Discord account. - Write a post in #contributor-applications with the following [you will have access to the channel once you read the directions]: 1. In English, tell us which language you would like to translate into (e.g. "I would like to translate into Russian …") and from (e.g. "… for speakers of Spanish"). 2. In English, provide your Duostories.org account name. 3. Write us an application message in the language you want to translate into (e.g. if you want to create Russian stories, write your application message in Russian). Show off your skills! Tell us about your background, your language experience and qualifications, your interests, and anything else you like. Just be sure to demonstrate native-level proficiency in your language. (If you're not a native speaker, be sure to have your native-speaker partner revise this before sending.) - Project admins, current Story contributors, and native speakers will review your application. If you're able to demonstrate native-level proficiency in your language and your language is currently technically possible, you will be given access to the Story Editor and further instructions. ================================================ FILE: public/docs/become-contributor/colang.mdx ================================================ --- title: "Conlangs/Dialects" description: "Does your language qualify for duostories." --- ### Conlangs #### If my language is a constructed language (conlang) or auxiliary language (auxlang), can I still translate stories into that language? While the primary focus of this project is to feature natural languages, we acknowledge that some conlangs/auxlangs are used to a similar extent as some minor natural languages. Esperanto is a well-known example with thousands of speakers worldwide. Especially because it is also taught on Duolingo, it makes sense to include it here. #### When is a conlang/auxlang established enough to be featured on the website? In order to maximize the benefits to learners, we will be more interested in featuring a conlang/auxlang when we see some of these factors, so please be sure to discuss them in your application. - There are a significant number of speakers/learners of the language (e.g. > 100) - The language has been in development for a significant period of time (e.g. 10+ years) - There are other websites or texts that provide material on the language (e.g. an official dictionary) - The language has received some degree of notoriety in the conlang/auxlang community (e.g. received awards, featured in articles/videos) - The language has its own Wikipedia edition (e.g. https://eo.wikipedia.org/, https://en.wikipedia.org/wiki/List_of_Wikipedias) and/or an ISO code (e.g. Esperanto, “epo”) If your language is a personal conlang/auxlang project or a very new project from a small group, we are hesitant to support the language as we do not see clear value for our community of learners. When we do allow these smaller languages access to the project, we will not feature them on the main page of Duostories.org. The course contributor would be given a direct link to share with interested learners. ### Dialects #### Can I translate stories into a dialect or regionally-specific language? We are hesitant to support languages that are too regionally-specific because at times they are not well-defined enough that a course would even make sense to learners. Applications for dialects/regional languages will be considered against a set of factors on a case-by-case basis. It might be a “yes” if your language: - Is classified as an “endangered” language - Has a well-defined written form and spelling - Has an ISO code - Has a language foundation or association to support it - Has a broad body of published literature ================================================ FILE: public/docs/docs.json ================================================ { "navigation": [ { "group": "", "pages": ["introduction"] }, { "group": "Become a Contributor", "pages": [ "become-contributor/application", "become-contributor/colang" ] }, { "group": "Story Importing", "pages": [ "story-creation/import", "story-creation/translate" ] }, { "group": "Story Editing", "pages": [ "story-editing/overview", "story-editing/translation-hints", "story-editing/exercises" ] }, { "group": "Audio Generation", "pages": [ "audio-generation/overview", "audio-generation/character-editor", "audio-generation/engines", "audio-generation/edit", "audio-generation/generate", "audio-generation/fix-problems" ] }, { "group": "Story Publishing", "pages": [ "story-publishing/publishing", "story-publishing/without_tts" ] } ] } ================================================ FILE: public/docs/introduction.mdx ================================================ --- title: "Introduction" description: "The guide to contributing to duostories." --- Welcome to Duostories Documentation. Here you can find explanations on how our contributors platform works. Learn how to become a contributor, how to edit stories and how to create audio. ================================================ FILE: public/docs/search.js ================================================ let data = null; let pages = null; async function getPageData(page) { try { const res = await fetch("/docs/" + page + ".mdx").then((res) => res.text()); let data = res.split("---"); let metadata = {}; for (let line of data[1].split("\n")) { let pos = line.indexOf(":"); if (pos === -1) continue; let key = line.slice(0, pos).trim(); let value = line.slice(pos + 1).trim(); metadata[key.trim()] = value.match(/\s*"(.*)"\s*/)[1]; } metadata.body = data[2]; let parts = []; let current_link = page; let current_index = undefined; for (let line of metadata.body.split("\n")) { line = line.trim(); if (line.substring(0, 1).match(/\w/)) { if (current_index !== undefined) { parts[current_index].text += " " + line; } else { parts.push({ type: "text", text: line, link: current_link }); current_index = parts.length - 1; } continue; } current_index = undefined; if (line.startsWith("#")) { parts.push({ type: "heading", text: line.match("#*s*(.*)")[1], link: current_link, }); } } metadata.parts = parts; metadata.link = page; return metadata; } catch (e) { return { title: path, body: "", link: path }; } } async function search() { if (!data) data = await (await fetch("/docs/docs.json")).json(); if (!pages) { pages = []; let promises = []; for (let group of data.navigation) { for (let page of group.pages) { promises.push(getPageData(page).then((page) => pages.push(page))); } } await Promise.all(promises); } let innerHTML = ""; for (let page of pages) { let found = false; for (let part of page.parts) { if (part.text.includes(this.value)) { if (!found) { found = true; innerHTML += `${page.title}`; } innerHTML += `${part.text}`; } } } document.getElementById("search_results").innerHTML = innerHTML; } function display_search(do_show) { if (do_show) { document.getElementById("search_modal").setAttribute("show", true); document.getElementById("blur2").setAttribute("show", true); document.getElementById("search_input").value = ""; document.getElementById("search_input").focus(); search(); } else { document.getElementById("search_modal").setAttribute("show", false); document.getElementById("blur2").setAttribute("show", false); } } function toggle(value) { return; if (value === undefined) { if (document.getElementById("container").getAttribute("show") == "true") { value = "false"; } else { value = "true"; } } document.getElementById("container").setAttribute("show", value); } function init() { document.getElementById("search_input").onkeyup = search; document.getElementById("search").onclick = () => display_search(true); document.getElementById("blur2").onclick = () => display_search(false); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { display_search(false); } if (e.key === "k" && e.ctrlKey) { display_search(true); e.preventDefault(); } }); document.getElementById("toggle").onclick = (e) => toggle(); document.getElementById("blur").onclick = (e) => toggle(); document.getElementById("close").onclick = (e) => toggle(); } document.addEventListener("DOMNodeInserted", (event) => { init(); }); document.addEventListener("DOMNodeRemoved", (event) => { init(); }); init(); ================================================ FILE: public/docs/story-creation/import.mdx ================================================ --- title: "Import Story" description: "Initialize a new story translation." --- Click the import button in the menu for your language, and click the story you'd like to add. You'll be redirected to the editor for that story. ![The import button](/docs/story-creation/import.png "Import") ### Importing from Spanish We import stories from Spanish, as they're the most extensive on Duolingo. But sometimes it can make sense to import from another language. We import from the "Spanish from English" course and not from the "English from Spanish" as this already has the hints in English, so it reduces the amount of text you need to change. ### Importing from other Languages You can change the last part of the import url, e.g. `es-en` to any other course to import from that course. ``` https://duostories.org/editor/course/nl-en/import/es-en ``` ### Intro and welcome stories If you're translating to a smaller language, or a conlang, a welcome story can be a good idea to show users wandering through the site what your language actually is, or if there is some useful information to show about the language. To make one, import a new story (you can choose any story here) and set the Set Number to 0 in the editor. ![Set 0|0](/docs/story-creation/set00.png "Set 0|0") I'd recommend looking through the stories you can import to find one with a nice icon. Then, write whatever you want to explain your language. Some good examples are @𝕰𝖓𝖝𝖆666's story for [Catalan](https://www.duostories.org/story/2179) and @Candy Butcher's story for [Interslavic](https://www.duostories.org/story/2030). Once your Welcome Story has been reviewed by another contributor and 👍 approved—contact a Moderator to manually publish it to the site. ### Can I write my own stories? Short answer is: no. The scope of the project is to bring the existing stories from Duolingo to new languages. These stories have been developed by a team of linguists, it would be very difficult to match their quality with our team of volunteers. It also makes it easier to review stories as we only need to review the translation and not the content of the new story. Is the content appropriate? Is the content well suited for a learner with the particular knowledge level, is the length of the story right, are the characters in the story according to the personality of the character cast, etc. If we have translated all stories we might consider expanding this but for now the best way is to stick with the stories from Duolingo to provide high quality content for our learners. ### Can I skip stories? Please follow the order of the stories from Duolingo. If we skip ahead it will make the organisation of the stories more difficult and can be an unpleasant experience for the learner if the difficulty level changes to much from one story to the next. If you think a story needs to be skipped for your language, please consult with a Moderator first. ================================================ FILE: public/docs/story-creation/translate.mdx ================================================ --- title: "How to Translate" description: "What to consider when translating." --- ### Translating the Story To translate, swap the Spanish (or other language) for the language you're translating to, and rearrange the English words to match up with your language's word order. If you need to change the English words to better fit your language, go ahead. ### What to change Similarly, feel free to change the currency to that of your language! ![This jacket is 100 dollars. In this example the currency is dollars.](/docs/story-creation/currency.png "You can use dollars!") For example, if you're making German stories, change the currency to Euros. The same thing applies to places mentioned ![I have a ticket to California. In this example the location is California.](/docs/story-creation/place.png "You can use other places!") Feel free to use whatever geographical places correspond with your language. ### English Stories on Duostories use the American English dialect just like stories on Duolingo. Most Duolingo courses use American English translations and teach American English in reverse courses. The stories are aligned with that material and use American English as well. As such, Duostories contributors should not try to convert stories to using any other dialects (including British English). This helps stories on Duostories stay aligned with Duolingo course material including current Duolingo stories. This also avoids story quality issues created by changing existing content, such as hints, to another dialect, and helps maintain consistency between stories of different courses. We understand that some contributors may not be as familiar with American English, so if you have any questions as you're contributing, feel free to reach out in #general-contributors. ================================================ FILE: public/docs/story-editing/exercises.mdx ================================================ --- title: "Exercises" description: "Reading comprehension questions to engage the learner." --- The Duostories are like the original Duolingo stories interactive, the learner is prompted with questions to make sure the learner understands the story. ### Multiple Choice ``` [MULTIPLE_CHOICE] > Priti can't find her keys. + Yes, that's right. - No, that's wrong. ``` The exercise consists of a question and two or more answers. The right answer line starts with a `+` sign, the others with a `-` sign. ![Example of Multiple Choice exercise](/docs/story-editing/multiple_choice.png "Multiple Choice") ### Arrange ``` [ARRANGE] > Tap what you hear Speaker560: ¡[(Necesito) (las~llaves) (de) (mi) (carro)!] ~ I~need the~keys of my car ``` The audio is played for the learner so that the learner now needs to reproduce the sentence by bringing the missing words into the right order. The part between the square brackets `[]` will be hidden and words in parentheses `()` are converted into buttons that the user can click to arrange them. ![Example of Arrange exercise](/docs/story-editing/arrange.png "Arrange Choice") ### Point to Phrase ``` [POINT_TO_PHRASE] > Choose the option that means "tired." Speaker560: (Perdón), mi amor, (estoy) (+cansada). ¡(Trabajo) mucho! ~ sorry my love I~am tired I~work a~lot ``` First the sentence is played in the story like a normal sentence. Then the story hides the hints and the learner has to pick the word from the sentence with the right meaning. Mark some words with parentheses `()` that will be shown as buttons. The right answer is indicated by a plus sign `+` after the opening parentheses `(` . ![Example of Point to Phrase exercise](/docs/story-editing/arrange.png "Point to Phrase") ### Select Phrase ``` [SELECT_PHRASE] > Select the missing phrase Speaker507: Hoy tengo [un~partido~importante]. ~ today I~have an~important~game + un partido importante - un batido importante - una parte imponente ``` Here the audio is played for the whole sentences but a part of the sentences is hidden. This part is enclosed in square brackets `[]`. Then the user has to select from three similar sounding alternatives what was really said in the sentences. ![Example of Select Phrase exercise](/docs/story-editing/select_phrase.png "Select Phrase") ### Continuation ``` [CONTINUATION] > What's next? Speaker508: Tienes cuatro botellas [de vino]. ~ you~have four bottles of wine - la mesa ~ the table - de pastel ~ of cake + de vino ~ of wine ``` The learner sees a sentences where a part is missing. This part will also be omitted by the audio. The learner then has to fill in the gap with one of three different alternatives. Although this seems very similar to `[SELECT_PHRASE]`, they are different as here its about filling in a missing word from the context whereas in `[SELECT_PHRASE]` is about listening to the audio. ![Example of Continuation exercise](/docs/story-editing/select_phrase.png "Continuation") ### Match ``` [MATCH] > Tap the pairs - estás <> you are - mucho <> a lot - es <> is - las llaves <> the keys - la <> the ``` The last exercise of a story is to match 5 words that occurred in the story with their translation. Write the word in the target language on the left followed by `<>` and on the right the translation. ![Example of Match exercise](/docs/story-editing/match.png "Match") ================================================ FILE: public/docs/story-editing/overview.mdx ================================================ --- title: "Overview" description: "The editor with the two views." --- Duostories is not like the software *Word* where you edit the final output directly, but you write the stories in some kind of "coding" language that will be converted to the final story display. This sounds complicated but in practice is easy to use and helps to see what is going on exactly. ### Editor The editor has two panels, on the left you can see the code that you can edit and on the right you see how the story will look like. Both views are synchronized when scrolling so that you have always the matching view. ### Comments ``` # This line is a comment ``` Sometimes it can be helpful to leave comments in the code. For example if you want to mark a translation where you want to consult another contributor. You can mark a line with the hash sign `#` to mark it as a comment that should not appear in the final story. ### Save You can save a story with a click on the ![save](/docs/story-editing/save_button.png "save") symbol. Changes to stories are tracked on [GitHub](https://github.com/rgerum/unofficial-duolingo-stories-content). ### Meta data ``` [DATA] fromLanguageName=A Family Dinner icon=076bc5bf725308c211b9c04f07d3622683e84d78 set=8|4 ``` On the top of the story there is some meta data that defines the name as it is shown in the story list, the icon that the story uses and the set. The set number is composed o the set id (e.g. Set 8) and the position within the set, the set index (e.g. the 4th story of the set). In some legacy stories you still see some character icon or speaker definitions here. They are only for compatibility with old stories and should not be used in new story translations. ================================================ FILE: public/docs/story-editing/translation-hints.mdx ================================================ --- title: "Translation Hints" description: "How to provide translation hints." --- An important part of learning with Duostories is to have the translation of a word in the story directly available. Therefore, when translation the story you also need to provide translation hints. The system works as follows: you write the sentence in one line and the translation below: ``` [LINE] > Jan is thuis met zijn vrouw, Marian. ~ ~ is home with his wife ~ ``` The sentence will be split into words and each word will be matched with the word on the sentence below. If a word is translated with "~", no translation will be displayed for the word. This is used for names where there is not need for a translation. The syntax highlighting will highlight words alternating in blue and green to help you see how it will perform the mapping. ### Joining Words Sometimes translations are more complicated than a one to one match of words. ``` [LINE] Speaker292: Weet~jij waar mijn lesboek Engels is? ~ do~you~know Where my textbook English is ``` Here you can "glue" words together using the tilde sign `~`. They are treated as one word for the sake of translation. So *"Weet jij"* will have the joined hint *"do you know"*. Words in the target language as well as the translation can be joined to create one to many, many to one, or many to many mappings. ### Splitting Words Sometimes languages do not separate words with spaces. A prominent example here is chinese script. ``` [LINE] Speaker560: 我的|钥匙|在哪里? ~ my keys (are)~where ``` Here you can use the vertical bar `|` (also known as pipe). Its the opposite of the title and splits words for the translation hints while it still appears without spaces in the final story. ### Show Hints With a click on the ![hints](/docs/story-editing/hints_button.png "Hints") button you can show the hints displayed directly below each word. Especially when reviewing stories this is a great tool. ![Example of hints display](/docs/story-editing/hint.png "Hints") ### Pronunciation Hints You can add an optional pronunciation hint line using `^` directly below the text (or below `~` if both are present). This works for pinyin and other pronunciation systems. ``` [LINE] Speaker560: 我的|钥匙|在哪里? ~ my keys (are)~where ^ wǒ~de yào~shi zài~nǎ~lǐ ``` The `^` line uses the same alignment rules as translation hints: - use spaces to align tokens, - use `~` to join words/tokens, - use `|` to split words/tokens. You can also attach pronunciation inline to a translation token by adding `{...}` in the `~` line: ``` ~ ... sit~and~{いー~or~うぃー} ... ``` This keeps the translation hint as `sit and` and sets the pronunciation hint for that same mapped phrase to `いー or うぃー`. Token alignment is shared with the main text, so each text token can have: - a translation hint from `~`, - a pronunciation hint from `^`, - both, or neither (`~` can still be used to suppress a hint for one token). Pronunciation hints from `^` are shown directly above the words. Translation hints from `~` keep the existing hint behavior (hover in story mode, inline in editor hint mode). ================================================ FILE: public/docs/story-publishing/publishing.mdx ================================================ --- title: "Publish a Story" description: "The review process for publishing." --- To make sure that the translated stories meet certain quality criteria, stories need to be reviewed first before being published. Stories are generally published in full sets of 4. When you have finished working on a story, you can click the "👍" icon to approve it and change the status to "🗨 feedback". You can now ask your team mates on discord to review the story and give their approval. When one or more people have checked the story and also gave their approval "👍" the status changes to "✅ finished". When one complete set is finished it will switch to "📢 published". If this is the first set of your language to be published, the chances are high that the course itself is not set to public yet. Talk to a moderator on Discord to publish your course. ### Criteria for Approval When you approve a story be sure to check the content carefully. - Are all the sentences translated into the target language? - Do the sentences have proper punctuations? E.g. end with a period `.` - Do all words (except names) have translation hints? - Does the story have audio? (Only if the language has audio available) - If the course does not have audio: Are all ARRANGE exercises converted to POINT_TO_PHRASE and SELECT_PHRASE to CONTINUATION? (see [Publish without Audio](https://duostories.org/docs/story-publishing/without_tts)) This is not a "Like" as you would give it on social media but a signature that you certify that the story is ready to be published. ### Need a set of stories reviewed? If you are a solo contributor for a course, or none of your teammates are available, and you have a full set of stories ready for review, create a post for your language, and others can volunteer to help out! Go to the #review-request channel. Please title your post with the names of the languages, so folks can find languages they are familiar with! It is also helpful if you include a link to your course in the Editor. You are welcome to collaborate on review and editing in your forum post here, if you don't have a language-specific contrib channel. ### Publish a "Intro/Welcome" story As welcome stories (see [Intro and welcome stories](https://duostories.org/docs/story-creation/import#intro-and-welcome-stories)) are not part of a complete set, they need to be approved by a Moderator. Please contact a Moderator on Discord once your "Intro/Welcome" story has at least two approvals 👍. ================================================ FILE: public/docs/story-publishing/without_tts.mdx ================================================ --- title: "Publish without Audio" description: "How to publish stories without TTS audio." --- Our goal is to have audio in all of the stories we publish, to best match the Duolingo experience, and to give learners practice at listening comprehension. But we can probably all agree that teaching incorrect pronunciation is worse than having no listening practice at all! After experimenting with the different TTS services and voices we have available (and the above tips and tricks), if you cannot find any that correctly pronounce your language, there are some edits you will need to make to your stories. Converting the "listening" questions to similar "comprehension" questions will give readers a better learning experience. If you want to make it easy to convert back to listening exercises when TTS voices become available in the future 🤞🏽, you can make a duplicate of the exercise, comment-out #the lines of the old one, and convert the new one. ### Convert "Arrange" to "Point to Phrase" - Change the exercise title from `[ARRANGE]` to `[POINT_TO_PHRASE]`. - Change the prompt-line from `> Tap what you hear` to `> Choose the option that means "."` - Remove the square brackets `[ ]` around the "Speaker"-line. - Select which word/phrase you will ask the learner to recognize, and mark it with a plus-sign inside the parentheses: `(+CorrectAnswer)`. - Add the hint for that word/phrase in the prompt-line: `> Choose the option that means "From~Language Hint."` Old: ``` #[ARRANGE] #> Tap what you hear #Speaker100: [(Babam) (Kanadalı) (ve) (annem) (Türkiyeli).] #~ my~father (is)~from~Canada and my~mother (is)~from~Turkey ``` New: ``` [POINT_TO_PHRASE] > Choose the option that means "my~mother." Speaker100: (Babam) (Kanadalı) (ve) (+annem) (Türkiyeli). ~ my~father (is)~from~Canada and my~mother (is)~from~Turkey ``` ### Convert "Select Phrase" to "Continuation" - Change the exercise title from `[SELECT_PHRASE]` to `[CONTINUATION]`. - Change the prompt-line from `> Select the missing phrase` to `> What comes next?` - Change your alternate answers from the "sound-alikes" useful on the listening exercise to answers that will not make sense in your sentence. - Add hint-lines after each of the multiple choice answers, like: `~ From~Language Hint` (using tildes and pipes as needed to align the hints and answers). Old: ``` #[SELECT_PHRASE] #> Select the missing phrase #Speaker507: Bugün [önemli~bir maçım] var. #~ today an~important my~game I~have #+ önemli bir maçım #- önemli bir maçımız #- önemsiz bir maçım ``` New: ``` [CONTINUATION] > What comes next? Speaker507: Bugün [önemli~bir maçım] var. ~ today an~important my~game I~have + önemli~bir maçım ~ an~important my~game - mor kurbağalarım ~ purple my~frogs - altı çarşambam ~ six my~Tuesday ``` ================================================ FILE: public/robots.txt ================================================ # Block all crawlers for /editor User-agent: * Disallow: /editor # Block all crawlers for /admin User-agent: * Disallow: /admin # Allow all crawlers User-agent: * Allow: / ================================================ FILE: public/sw.js ================================================ /** * Copyright 2018 Google Inc. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // If the loader is already loaded, just stop. if (!self.define) { let registry = {}; // Used for `eval` and `importScripts` where we can't get script URL by other means. // In both cases, it's safe to use a global var because those functions are synchronous. let nextDefineUri; const singleRequire = (uri, parentUri) => { uri = new URL(uri + ".js", parentUri).href; return registry[uri] || ( new Promise(resolve => { if ("document" in self) { const script = document.createElement("script"); script.src = uri; script.onload = resolve; document.head.appendChild(script); } else { nextDefineUri = uri; importScripts(uri); resolve(); } }) .then(() => { let promise = registry[uri]; if (!promise) { throw new Error(`Module ${uri} didn’t register its module`); } return promise; }) ); }; self.define = (depsNames, factory) => { const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; if (registry[uri]) { // Module is already loading or loaded. return; } let exports = {}; const require = depUri => singleRequire(depUri, uri); const specialDeps = { module: { uri }, exports, require }; registry[uri] = Promise.all(depsNames.map( depName => specialDeps[depName] || require(depName) )).then(deps => { factory(...deps); return exports; }); }; } define(['./workbox-8817a5e5'], (function (workbox) { 'use strict'; importScripts(); self.skipWaiting(); workbox.clientsClaim(); workbox.registerRoute("/", new workbox.NetworkFirst({ "cacheName": "start-url", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }); } return response; } }] }), 'GET'); workbox.registerRoute(/.*/i, new workbox.NetworkOnly({ "cacheName": "dev", plugins: [] }), 'GET'); })); ================================================ FILE: scripts/backfill-course-contributors.ts ================================================ import dotenv from "dotenv"; dotenv.config({ path: ".env.local" }); const CONVEX_SITE_URL = process.env.NEXT_PUBLIC_CONVEX_SITE_URL ?? process.env.CONVEX_SITE_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL; const COURSE_CONTRIBUTOR_BACKFILL_SECRET = process.env.COURSE_CONTRIBUTOR_BACKFILL_SECRET; const BATCH_SIZE = parsePositiveNumber( process.env.COURSE_CONTRIBUTOR_BACKFILL_BATCH_SIZE, 10, ); const BATCH_DELAY_MS = parsePositiveNumber( process.env.COURSE_CONTRIBUTOR_BACKFILL_BATCH_DELAY_MS, 0, ); const DRY_RUN = parseBooleanEnv( process.env.COURSE_CONTRIBUTOR_BACKFILL_DRY_RUN, false, ); if (!CONVEX_SITE_URL) { console.error( "Error: NEXT_PUBLIC_CONVEX_SITE_URL/CONVEX_SITE_URL/CONVEX_URL is not set.", ); process.exit(1); } if (!COURSE_CONTRIBUTOR_BACKFILL_SECRET) { console.error("Error: COURSE_CONTRIBUTOR_BACKFILL_SECRET is not set."); process.exit(1); } type BackfillResult = { processed: number; updatedCourses: number; nextCursor: string | null; isDone: boolean; errors: Array<{ courseId: number; message: string; }>; }; function parsePositiveNumber(value: string | undefined, fallback: number) { if (value === undefined || value.trim() === "") return fallback; const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { console.error("Error: batch values must be positive numbers."); process.exit(1); } return Math.floor(parsed); } function parseBooleanEnv(value: string | undefined, defaultValue: boolean) { if (value === undefined) return defaultValue; const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; if (["0", "false", "no", "n", "off"].includes(normalized)) return false; return defaultValue; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runBatch(baseUrl: string, cursor: string | null) { const response = await fetch(`${baseUrl}/admin/backfill-course-contributors`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ secret: COURSE_CONTRIBUTOR_BACKFILL_SECRET, batchSize: BATCH_SIZE, cursor, dryRun: DRY_RUN, }), }); const text = await response.text(); let parsed: unknown = null; try { parsed = JSON.parse(text); } catch { parsed = text; } if (!response.ok) { throw new Error( `Backfill request failed with ${response.status}: ${JSON.stringify(parsed)}`, ); } return parsed as BackfillResult; } async function main() { const baseUrl = String(CONVEX_SITE_URL).replace(/\/+$/, ""); let cursor: string | null = null; let processedTotal = 0; let updatedCoursesTotal = 0; const errors: BackfillResult["errors"] = []; let batchNumber = 0; while (true) { batchNumber += 1; console.log( `Running batch ${batchNumber} (size=${BATCH_SIZE}, cursor=${cursor ?? "start"})...`, ); const result = await runBatch(baseUrl, cursor); processedTotal += result.processed; updatedCoursesTotal += result.updatedCourses; errors.push(...result.errors); console.log( `Batch ${batchNumber} complete: processed=${result.processed}, updatedCourses=${result.updatedCourses}, errors=${result.errors.length}`, ); if (result.isDone || !result.nextCursor || result.processed === 0) { break; } cursor = result.nextCursor; if (BATCH_DELAY_MS > 0) { await sleep(BATCH_DELAY_MS); } } console.log("Course contributor backfill completed."); console.log( JSON.stringify( { processed: processedTotal, updatedCourses: updatedCoursesTotal, errors, }, null, 2, ), ); } main().catch((error) => { console.error("Course contributor backfill failed."); console.error(error); process.exit(1); }); ================================================ FILE: scripts/backfill-discord-avatars.ts ================================================ import dotenv from "dotenv"; dotenv.config({ path: ".env.local" }); const CONVEX_SITE_URL = process.env.NEXT_PUBLIC_CONVEX_SITE_URL ?? process.env.CONVEX_SITE_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL; const DISCORD_AVATAR_SYNC_SECRET = process.env.DISCORD_AVATAR_SYNC_SECRET; const TOTAL_LIMIT = parseOptionalNumber(process.env.DISCORD_AVATAR_SYNC_LIMIT); const BATCH_SIZE = parsePositiveNumber( process.env.DISCORD_AVATAR_SYNC_BATCH_SIZE, 25, ); const BATCH_DELAY_MS = parsePositiveNumber( process.env.DISCORD_AVATAR_SYNC_BATCH_DELAY_MS, 0, ); const DRY_RUN = parseBooleanEnv(process.env.DISCORD_AVATAR_SYNC_DRY_RUN, false); if (!CONVEX_SITE_URL) { console.error( "Error: NEXT_PUBLIC_CONVEX_SITE_URL/CONVEX_SITE_URL/CONVEX_URL is not set.", ); process.exit(1); } if (!DISCORD_AVATAR_SYNC_SECRET) { console.error("Error: DISCORD_AVATAR_SYNC_SECRET is not set."); process.exit(1); } type BackfillResult = { processed: number; updatedUsers: number; updatedAccounts: number; skipped: number; nextCursor: string | null; isDone: boolean; errors: Array<{ accountId: string | null; userId: string | null; message: string; }>; }; function parseOptionalNumber(value: string | undefined) { if (value === undefined || value.trim() === "") return undefined; const parsed = Number(value); if (!Number.isFinite(parsed)) { console.error("Error: DISCORD_AVATAR_SYNC_LIMIT must be a valid number."); process.exit(1); } return parsed; } function parsePositiveNumber(value: string | undefined, fallback: number) { if (value === undefined || value.trim() === "") return fallback; const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { console.error("Error: batch values must be positive numbers."); process.exit(1); } return Math.floor(parsed); } function parseBooleanEnv(value: string | undefined, defaultValue: boolean) { if (value === undefined) return defaultValue; const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; if (["0", "false", "no", "n", "off"].includes(normalized)) return false; return defaultValue; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runBatch( baseUrl: string, cursor: string | null, batchSize: number, ) { const response = await fetch(`${baseUrl}/admin/backfill-discord-avatars`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ secret: DISCORD_AVATAR_SYNC_SECRET, batchSize, cursor, dryRun: DRY_RUN, }), }); const text = await response.text(); let parsed: unknown = null; try { parsed = JSON.parse(text); } catch { parsed = text; } if (!response.ok) { throw new Error( `Backfill request failed with ${response.status}: ${JSON.stringify(parsed)}`, ); } return parsed as BackfillResult; } async function main() { const baseUrl = String(CONVEX_SITE_URL).replace(/\/+$/, ""); let cursor: string | null = null; let processedTotal = 0; let updatedUsersTotal = 0; let updatedAccountsTotal = 0; let skippedTotal = 0; const errors: BackfillResult["errors"] = []; let batchNumber = 0; while (true) { const remaining = typeof TOTAL_LIMIT === "number" ? TOTAL_LIMIT - processedTotal : undefined; if (remaining !== undefined && remaining <= 0) break; const currentBatchSize = remaining !== undefined ? Math.min(BATCH_SIZE, remaining) : BATCH_SIZE; batchNumber += 1; console.log( `Running batch ${batchNumber} (size=${currentBatchSize}, cursor=${cursor ?? "start"})...`, ); const result = await runBatch(baseUrl, cursor, currentBatchSize); processedTotal += result.processed; updatedUsersTotal += result.updatedUsers; updatedAccountsTotal += result.updatedAccounts; skippedTotal += result.skipped; errors.push(...result.errors); console.log( `Batch ${batchNumber} complete: processed=${result.processed}, updatedUsers=${result.updatedUsers}, updatedAccounts=${result.updatedAccounts}, skipped=${result.skipped}, errors=${result.errors.length}`, ); if ( result.isDone || !result.nextCursor || result.processed === 0 || (remaining !== undefined && typeof TOTAL_LIMIT === "number" && processedTotal >= TOTAL_LIMIT) ) { break; } cursor = result.nextCursor; if (BATCH_DELAY_MS > 0) { await sleep(BATCH_DELAY_MS); } } console.log("Discord avatar backfill completed."); console.log( JSON.stringify( { processed: processedTotal, updatedUsers: updatedUsersTotal, updatedAccounts: updatedAccountsTotal, skipped: skippedTotal, errors, }, null, 2, ), ); } main().catch((error) => { console.error("Discord avatar backfill failed."); console.error(error); process.exit(1); }); ================================================ FILE: scripts/find-missing-story-images.ts ================================================ import dotenv from "dotenv"; import { ConvexHttpClient } from "convex/browser"; import { api } from "../convex/_generated/api"; import { mkdir, writeFile } from "node:fs/promises"; dotenv.config({ path: ".env.local" }); const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL; const CONCURRENCY = Number(process.env.STORY_IMAGE_AUDIT_CONCURRENCY ?? "20"); const PUBLISHED_ONLY = parseBooleanEnv( process.env.STORY_IMAGE_AUDIT_PUBLISHED_ONLY, true, ); if (!CONVEX_URL) { console.error("Error: NEXT_PUBLIC_CONVEX_URL/CONVEX_URL is not set."); process.exit(1); } if (!Number.isFinite(CONCURRENCY) || CONCURRENCY <= 0) { console.error("Error: STORY_IMAGE_AUDIT_CONCURRENCY must be a positive number."); process.exit(1); } const client = new ConvexHttpClient(CONVEX_URL); type StorySummary = { id: number; name: string; course_id: number; image: string; course_short: string | null; public: boolean; }; type Finding = { storyId: number; storyName: string; courseId: number; courseShort: string; reasons: string[]; }; async function mapWithConcurrency( items: T[], concurrency: number, worker: (item: T, index: number) => Promise, ): Promise { const results: R[] = new Array(items.length); let cursor = 0; async function runWorker() { while (true) { const index = cursor; cursor += 1; if (index >= items.length) return; results[index] = await worker(items[index], index); } } const workers = Array.from( { length: Math.min(concurrency, items.length) }, () => runWorker(), ); await Promise.all(workers); return results; } function parseBooleanEnv(value: string | undefined, defaultValue: boolean) { if (value === undefined) return defaultValue; const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; if (["0", "false", "no", "n", "off"].includes(normalized)) return false; return defaultValue; } async function getAllStories(): Promise { const sidebar = await client.query(api.editorRead.getEditorSidebarData, {}); const courses = sidebar.courses ?? []; const storiesByCourse = await mapWithConcurrency( courses, Math.min(CONCURRENCY, 10), async (course) => { const identifier = String(course.id); const stories = await client.query( api.editorRead.getEditorStoriesByCourseLegacyId, { identifier }, ); return stories.map((story) => ({ id: story.id, name: story.name, course_id: story.course_id, image: story.image, course_short: course.short, public: story.public, })); }, ); return storiesByCourse.flat(); } async function main() { console.log("Loading course/story lists..."); const allStories = await getAllStories(); const stories = PUBLISHED_ONLY ? allStories.filter((story) => story.public) : allStories; console.log( `Found ${allStories.length} stories total; scanning ${stories.length} ${PUBLISHED_ONLY ? "published" : "all"} stories for image fields...`, ); const findings = ( await mapWithConcurrency(stories, CONCURRENCY, async (story, index) => { if (index > 0 && index % 200 === 0) { console.log(`Checked ${index}/${stories.length} stories...`); } const reasons: string[] = []; const detail = await client.query(api.storyRead.getStoryByLegacyId, { storyId: story.id, }); if (!detail) { reasons.push("missing_story_payload"); } else { if (!detail.illustrations.active.trim()) { reasons.push("missing_illustration_active"); } if (!detail.illustrations.gilded.trim()) { reasons.push("missing_illustration_gilded"); } if (!detail.illustrations.locked.trim()) { reasons.push("missing_illustration_locked"); } } if (!story.image.trim()) { reasons.push("missing_story_image_id"); } if (reasons.length === 0) return null; return { storyId: story.id, storyName: story.name, courseId: story.course_id, courseShort: story.course_short ?? "", reasons, } satisfies Finding; }) ).filter((finding): finding is Finding => finding !== null); const outputPath = PUBLISHED_ONLY ? "tmp/missing-story-images-published.json" : "tmp/missing-story-images-all.json"; await mkdir("tmp", { recursive: true }); await writeFile( outputPath, JSON.stringify( { generatedAt: new Date().toISOString(), publishedOnly: PUBLISHED_ONLY, totalStoriesFound: allStories.length, totalStoriesScanned: stories.length, totalIssues: findings.length, findings, }, null, 2, ) + "\n", "utf8", ); console.log("\n=== Image Audit Summary ==="); console.log(`Total stories scanned: ${stories.length}`); console.log(`Stories with issues: ${findings.length}`); if (findings.length === 0) { console.log("No stories with missing images were found."); console.log(`Results saved to ${outputPath}`); return; } const reasonCount = new Map(); for (const finding of findings) { for (const reason of finding.reasons) { reasonCount.set(reason, (reasonCount.get(reason) ?? 0) + 1); } } console.log("\nIssue counts by reason:"); for (const [reason, count] of Array.from(reasonCount.entries()).sort((a, b) => a[0].localeCompare(b[0]), )) { console.log(`- ${reason}: ${count}`); } console.log("\nAffected stories:"); for (const finding of findings.sort((a, b) => a.storyId - b.storyId)) { console.log( `- story=${finding.storyId} course=${finding.courseId} (${finding.courseShort}) name=${JSON.stringify(finding.storyName)} reasons=${finding.reasons.join(",")}`, ); } console.log(`\nResults saved to ${outputPath}`); } main().catch((error) => { console.error("Story image audit failed:"); console.error(error); process.exit(1); }); ================================================ FILE: skills-lock.json ================================================ { "version": 1, "skills": { "convex": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex/SKILL.md", "computedHash": "70ecfb9cd4439ccbf6570b6dc23eab53f7ce7dcf70ef63bbfdf8f4f21353dfb4" }, "convex-create-component": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex-create-component/SKILL.md", "computedHash": "e4ad9cbe6d2bb0d5171dfd04019bc4ff228f26fb52312429376c885d2ec4935a" }, "convex-migration-helper": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex-migration-helper/SKILL.md", "computedHash": "c6416032d2f2e947ebe9d6b2389d89592d0229a0e6c4202f9a1197f2bd76019f" }, "convex-performance-audit": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex-performance-audit/SKILL.md", "computedHash": "c048b44beca5616108bfebc9822b6238cbff5c99facb88b3cf3d3a2af0dac502" }, "convex-quickstart": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex-quickstart/SKILL.md", "computedHash": "c95728c430a441325c865b06f0f0e912923c34deecbf6f24e9f03e13046b469c" }, "convex-setup-auth": { "source": "get-convex/agent-skills", "sourceType": "github", "skillPath": "skills/convex-setup-auth/SKILL.md", "computedHash": "f60559165edd5b616fda726ed5726c798e33f905361ed9892ab6013e53ab2588" } } } ================================================ FILE: src/app/(stories)/(main)/EditorCommandPaletteClient.tsx ================================================ "use client"; import EditorCommandPalette from "@/app/editor/_components/editor_command_palette"; import { authClient } from "@/lib/auth-client"; type SessionUser = { role?: string | null; }; export default function EditorCommandPaletteClient() { const { data: session } = authClient.useSession(); const sessionUser = (session?.user ?? null) as SessionUser | null; const role = sessionUser?.role ?? null; const showCommandPalette = role === "contributor" || role === "admin"; if (!showCommandPalette) return null; return ; } ================================================ FILE: src/app/(stories)/(main)/[course_id]/course_page_client.tsx ================================================ "use client"; import React from "react"; import { api } from "@convex/_generated/api"; import { Preloaded, usePreloadedQuery, useQuery } from "convex/react"; import Header from "../header"; import StoryButton from "./story_button"; import get_localisation_func from "@/lib/get_localisation_func"; import ContributorList from "@/components/ContributorList"; import Switch from "@/components/ui/switch"; function SetTitle({ children }: { children: React.ReactNode }) { return (
{children}
); } function SetGrid({ setId, setName, children, }: { setId: number; setName: React.ReactNode; children: React.ReactNode; }) { return (
    {setName} {children}
); } function About({ about }: { about: string }) { if (!about) return <>; return (
About

{about}

); } function Contributors({ contributors, contributorsPast, }: { contributors: Array<{ legacyUserId: number; name: string; image: string | null; discordLinked: boolean; }>; contributorsPast: Array<{ legacyUserId: number; name: string; image: string | null; discordLinked: boolean; }>; }) { const allContributors = [...contributors, ...contributorsPast].filter( (contributor, index, list) => index === list.findIndex( (candidate) => candidate.legacyUserId === contributor.legacyUserId && candidate.name === contributor.name, ), ); if (allContributors.length === 0) return null; return (
Contributors
); } function NoNativeWarning() { return (

Course quality note

This course does not currently have a native speaker translator or proofreader, so some stories may not be 100% correct. If you are a native speaker and want to help improve this course, please join our{" "} Discord server .

); } export default function CoursePageClient({ course_id, preloadedCourse, }: { course_id: string; preloadedCourse: Preloaded; }) { const listeningStorageKey = React.useMemo( () => `course_listening_mode:${course_id}`, [course_id], ); const [listeningMode, setListeningMode] = React.useState(false); React.useEffect(() => { if (typeof window === "undefined") return; setListeningMode(window.localStorage.getItem(listeningStorageKey) === "1"); }, [listeningStorageKey]); const toggleListeningMode = React.useCallback(() => { setListeningMode((prev) => { const next = !prev; if (typeof window !== "undefined") { window.localStorage.setItem(listeningStorageKey, next ? "1" : "0"); } return next; }); }, [listeningStorageKey]); const course = usePreloadedQuery(preloadedCourse); const localizationMap = React.useMemo(() => { const data: Record = {}; for (const row of course?.localization ?? []) data[row.tag] = row.text; return data; }, [course]); const localization = React.useMemo( () => get_localisation_func(localizationMap), [localizationMap], ); const doneStoryIds = useQuery( api.storyDone.getDoneStoryIdsForCurrentUserInCourse, { courseShort: course_id }, ); const doneMap = React.useMemo(() => { const done: Record = {}; for (const storyId of doneStoryIds ?? []) done[storyId] = true; return done; }, [doneStoryIds]); const storiesBySet = React.useMemo(() => { if (!course) return []; const grouped: Record = {}; for (const story of course.stories) { if (!grouped[story.set_id]) grouped[story.set_id] = []; grouped[story.set_id].push(story); } return Object.entries(grouped) .map(([setId, stories]) => ({ setId: Number.parseInt(setId, 10), stories: stories.sort((a, b) => a.set_index - b.set_index), })) .sort((a, b) => a.setId - b.setId); }, [course]); if (!course) { return (

Course not found.

); } const rawTags = course.tags ?? []; const normalizedTags = rawTags.map((tag) => tag.trim().toLowerCase()); const showNoNativeWarning = normalizedTags.includes("no-native"); return ( <>

{localization("course_page_title", { $language: course.learning_language_name, }) ?? `${course.learning_language_name} Duolingo Stories`}

{localization("course_page_sub_title", { $language: course.learning_language_name, $count: `${course.count}`, }) ?? `Learn ${course.learning_language_name} with ${course.count} stories.`}

{localization("course_page_discuss", {}, [ "https://discord.gg/4NGVScARR3", "/faq", ])}

{showNoNativeWarning ? : null}
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleListeningMode(); } }} >
Listening mode (skip questions)
Opens stories in autoplay and skips interactive questions.
{ e.stopPropagation(); }} >
{course.about ? : null} {storiesBySet.map((set) => ( {set.stories.map((story) => (
  • ))}
    ))}
    ); } ================================================ FILE: src/app/(stories)/(main)/[course_id]/not-found.tsx ================================================ import Header from "../header"; import Link from "next/link"; export default function NotFound() { return (

    Course Not Found

    This course does not exist or is not published yet.

    Go back to the main page.

    ); } ================================================ FILE: src/app/(stories)/(main)/[course_id]/page.tsx ================================================ import React from "react"; import { notFound } from "next/navigation"; import CoursePageClient from "./course_page_client"; import { get_localisation_by_convex_language_id } from "@/lib/get_localisation"; import { get_course_data, get_course } from "../get_course_data"; import { ResolvingMetadata } from "next"; import { preloadQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; export async function generateMetadata( { params }: { params: Promise<{ course_id: string }> }, parent: ResolvingMetadata, ) { const params0 = await params; if ( params0.course_id.indexOf("-") === -1 || params0.course_id.indexOf(".") !== -1 ) { return notFound(); } const course = await get_course(params0.course_id); if (!course) notFound(); const localization = await get_localisation_by_convex_language_id( course.fromLanguageId, ); const meta = await parent; return { title: localization("meta_course_title", { $language: course.learning_language_name, }) || `${course.learning_language_name} Duolingo Stories`, description: localization("meta_course_description", { $language: course.learning_language_name, }) || `Improve your ${course.learning_language_name} learning by community-translated Duolingo stories.`, alternates: { canonical: `https://duostories.org/${params0.course_id}`, }, openGraph: { images: [ `/api/og-course?lang=${params0.course_id.split("-")[0]}&count=${ course.count }&name=${course.learning_language_name}`, ], url: `https://duostories.org/${params0.course_id}`, type: "website", }, keywords: [course.learning_language_name, ...(meta.keywords || [])], }; } export async function generateStaticParams() { try { const courses = await get_course_data(); return courses.map((course) => ({ course_id: course.short, })); } catch (error) { console.error("generateStaticParams failed for /[course_id]:", error); return []; } } export default async function Page({ params, }: { params: Promise<{ course_id: string }>; }) { const course_id = (await params).course_id; if (course_id.indexOf("-") === -1 || course_id.indexOf(".") !== -1) { return notFound(); } const preloadedCourse = await preloadQuery( api.landing.getPublicCoursePageData, { short: course_id, }, ); return ( ); } ================================================ FILE: src/app/(stories)/(main)/[course_id]/story_button.tsx ================================================ "use client"; import Link from "next/link"; import Image from "next/image"; interface StoryData { id: number; name: string; active: string; gilded: string; active_lip: string; } export default function StoryButton({ story, done, listeningMode = false, }: { story?: StoryData; done?: boolean; listeningMode?: boolean; }) { if (!story) { return (
    ); } return ( { if (listeningMode) return; if (typeof window !== "undefined") { window.sessionStorage.setItem( "story_autoplay_ts", String(Date.now()), ); } }} >
    {listeningMode ? ( ) : null}
    {story.name}
    ); } ================================================ FILE: src/app/(stories)/(main)/course-dropdown.tsx ================================================ "use client"; import Link from "next/link"; import LanguageFlag from "@/components/ui/language-flag"; import { useSelectedLayoutSegment } from "next/navigation"; import { CourseData } from "@/app/(stories)/(main)/get_course_data"; import { api } from "@convex/_generated/api"; import { useQuery } from "convex/react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/shadcn"; function LanguageButtonSmall({ course }: { course?: CourseData }) { /** * A button in the language drop down menu (flag + name) */ if (!course) return null; return ( {course.name} ); } export default function CourseDropdown() { const course_data = useQuery(api.landing.getPublicCourseList, {}); const course_data_active = useQuery( api.storyDone.getDoneCourseIdsForUser, {}, ); function get_course_by_id(id: number) { if (!course_data) return undefined; for (let course of course_data) { if (course.id === id) return course; } } function get_course_by_short(short: string) { if (!course_data) return undefined; for (let course of course_data) { if (course.short === short) return course; } } const segment = useSelectedLayoutSegment(); let course = get_course_by_short(segment || ""); if (!course_data_active || course_data_active.length === 0) return null; return ( ); } ================================================ FILE: src/app/(stories)/(main)/course_list.tsx ================================================ "use client"; import React from "react"; import LanguageButton, { type LandingCourseButtonData, } from "./language_button"; import { api } from "@convex/_generated/api"; import { type Preloaded, usePreloadedQuery } from "convex/react"; import type { Id } from "@convex/_generated/dataModel"; interface LandingGroupData { fromLanguageId: Id<"languages">; labels: { storiesFor: string; nStoriesTemplate: string; }; courses: LandingCourseButtonData[]; } function RenderCourseGroups({ groups }: { groups: LandingGroupData[] }) { let startIndex = 0; return ( <> {groups.map((group) => { const currentStart = startIndex; startIndex += group.courses.length; return (

    {group.labels.storiesFor}
      {group.courses.map((course, index) => (
    1. ))}
    ); })} ); } export default function CourseList({ preloadedLandingData, }: { preloadedLandingData: Preloaded; }) { const landingData = usePreloadedQuery(preloadedLandingData); return ; } ================================================ FILE: src/app/(stories)/(main)/faq/page.tsx ================================================ import React from "react"; import Link from "next/link"; export const metadata = { title: "Duostories FAQ", description: "Information about the duostories project.", alternates: { canonical: "https://duostories.org/faq", }, }; export default async function Page() { return (

    Frequently Asked Questions

    Can I support this project financially?

    Yes, we have a page on{" "} OpenCollective . We use the money to cover the hosting costs and for the TTS services. {" You can "} contribute .

    Is this website open source?

    Yes it is! The code is hosted on Github{" "} rgerum/unofficial-duolingo-stories

    If you like it you can give it a star.{" "} GitHub Repo stars

    When will these stories be on the official Duolingo website?

    Probably never. This project is not linked to Duolingo in any way. Duolingo has in the past worked with volunteers but they stopped the volunteer program. Therefore, it is highly unlikely that Duolingo will adopt these stories.

    Are you allowed to use the material of Duolingo?

    Yes, we asked Duolingo for permission and came to an agreement that we are allowed to use the story material for this purpose. If you want to use Duolingo material, please ask them. Our licence agreement only covers this website.

    Can I contribute?

    Yes! The project is run by volunteers that want to bring the Duolingo stories to new languages. You can join us on{" "} Discord.

    Will you add a course in language X?

    If we have a volunteer, or better yet, a group of volunteers, then yes. Maybe you can spread the word, find some native speakers in your target language, and bring them to our{" "} Discord server.

    What about a dialect or regionally-specific language?

    We are hesitant to support languages that are too regionally-specific because at times they are not well-defined enough that a course would even make sense to learners. Applications for dialects/regional languages will be considered against a set of factors on a case-by-case basis. It might be a “yes” if your language:

    • Is classified as an “endangered” language
    • Has a well-defined written form and spelling
    • Has an ISO code
    • Has a language foundation or association to support it
    • Has a broad body of published literature

    What about a constructed language (conlang) or auxiliary language (auxlang)?

    While the primary focus of this project is to feature natural languages, we acknowledge that some conlangs/auxlangs are used to a similar extent as some minor natural languages. Esperanto is a well-known example with thousands of speakers worldwide. Especially because it is also taught on Duolingo, it makes sense to include it here.{" "}

    In order to maximize the benefits to learners, we will be more interested in featuring a conlang/auxlang when we see some of these factors, so please be sure to discuss them in your application.

    • There are a significant number of speakers/learners of the language (e.g. > 100)
    • The language has been in development for a significant period of time (e.g. 10+ years)
    • There are other websites or texts that provide material on the language (e.g. an official dictionary)
    • The language has received some degree of notoriety in the conlang/auxlang community (e.g. received awards, featured in articles/videos)
    • The language has its own Wikipedia edition (e.g.{" "} Esperanto Wikipedia,{" "} List of Wikipedias ) and/or an ISO code (e.g. Esperanto, “epo”)

    If your language is a personal conlang/auxlang project or a very new project from a small group, we are hesitant to support the language as we do not see clear value for our community of learners. When we do allow these smaller languages access to the project, we will not feature them on the main page of Duostories.org. The course contributor would be given a direct link to share with interested learners.

    Can I write my own stories as a contributor?

    Our current goal is to create good translations of the existing Duolingo stories. Duolingo has put great effort into developing stories that help learners to learn a new language using stories. We do not have the resources to create similar high quality stories, nor do we see the need to go beyond the current stories. Maybe when we have finished translating all of them ;-).

    I found a mistake!

    Yes, despite our continuous efforts, there might be mistakes in the translations. You can reach us on{" "} Discord to report mistakes.

    I found a bug on the page or want to suggest a new feature.

    We have a{" "} bugtracker {" "} on Github where you can report issues or feature requests. Or again discuss them with us on{" "} Discord.

    Who runs this website?

    {`The website was developed by me, "randrian". You can find me on `} Duolingo{" "} or on Github. Some people did minor contributions to the website, see the Github repository. You are welcome to be part of them.

    I am in no way associated with Duolingo.

    But of course this website would be nothing without its active group of contributors! Meet them on{" "} Discord.

    ); } ================================================ FILE: src/app/(stories)/(main)/footer_links.tsx ================================================ import Link from "next/link"; import React from "react"; export default async function FooterLinks({}) { return ( <>
    Contribute to our collective
    Social
    Contribute
    Legal
    Open Source
    Apps
    ); } ================================================ FILE: src/app/(stories)/(main)/get_course_data.ts ================================================ import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; import { fetchQuery } from "convex/nextjs"; export interface CourseData { id: number; short: string; name: string; count: number; about: string; tags: string[]; from_language: number; fromLanguageId: Id<"languages">; from_language_name: string; learning_language: number; learningLanguageId: Id<"languages">; learning_language_name: string; } export async function get_course_data() { return await fetchQuery(api.landing.getPublicCourseList, {}); } export async function get_course(short: string) { for (let course of await get_course_data()) { if (course.short === short) { return course; } } return null; } ================================================ FILE: src/app/(stories)/(main)/header.tsx ================================================ export default function Header({ children }: { children: React.ReactNode }) { return (
    h1]:m-0 [&>h1]:mb-[18px] [&>h1]:text-center [&>h1]:text-[calc(36/16*1rem)] [&>h1]:font-bold [&>h1]:leading-[1.2] " + "[&>p]:mx-auto [&>p]:mb-4 [&>p]:max-w-[700px] [&>p]:text-center [&>p]:text-[calc(19/16*1rem)] [&>p]:leading-[1.5] " + "[&>p:last-child]:mb-0 " + "max-[480px]:[&>h1]:mb-[10px] max-[480px]:[&>h1]:text-[calc(25/16*1rem)] " + "max-[480px]:[&>p]:text-[calc(19/16*1rem)] max-[480px]:[&>p]:text-[var(--title-color-dim)] max-[480px]:[&_a]:text-[var(--title-color-dim)]" } > {children}
    ); } ================================================ FILE: src/app/(stories)/(main)/icons.tsx ================================================ import Link from "next/link"; import { IconDiscord, IconGithub, IconInstagram, IconOpenCollective, IconTwitter, } from "@/components/icons"; import { IconPlayStore } from "@/components/icons"; export default function Icons() { return (

    ); } ================================================ FILE: src/app/(stories)/(main)/landing_stats_client.tsx ================================================ "use client"; import { api } from "@convex/_generated/api"; import { Preloaded, usePreloadedQuery, useQuery } from "convex/react"; function LandingStatsText({ stats, }: { stats: | { courseCount: number; storyCount: number; } | undefined; }) { if (!stats) { return <>... stories in ... courses and counting!; } return ( <> {stats.storyCount} stories in {stats.courseCount} courses and counting! ); } function LandingStatsClientPreloaded({ preloadedLandingData, }: { preloadedLandingData: Preloaded; }) { const landingData = usePreloadedQuery(preloadedLandingData); return ; } function LandingStatsClientQuery() { const landingData = useQuery(api.landing.getPublicLandingPageData, {}); return ; } export default function LandingStatsClient({ preloadedLandingData, }: { preloadedLandingData?: Preloaded; }) { if (preloadedLandingData) { return ( ); } return ; } ================================================ FILE: src/app/(stories)/(main)/language_button.tsx ================================================ "use client"; import Link from "next/link"; import Flag from "@/components/ui/flag"; export interface LandingCourseButtonData { id: number; short: string; name: string; count: number; learningLanguage: { short: string; flag?: number | string; flag_file?: string; }; } export default function LanguageButton({ course, storiesTemplate, loading, eagerFlagImage, }: { course?: LandingCourseButtonData; storiesTemplate?: string; loading?: boolean; eagerFlagImage?: boolean; }) { if (loading) { return (
    ); } if (!course) return null; return ( {course.name} {storiesTemplate?.replaceAll("$count", `${course.count}`) ?? `${course.count} stories`} ); } ================================================ FILE: src/app/(stories)/(main)/layout.tsx ================================================ import Link from "next/link"; import React from "react"; import CourseDropdown from "./course-dropdown"; import EditorCommandPaletteClient from "./EditorCommandPaletteClient"; import FooterLinks from "./footer_links"; import Legal from "@/components/layout/legal"; import Image from "next/image"; import { LoggedInButtonWrappedClient } from "@/components/login/LoggedInButtonWrappedClient"; export const metadata = { title: "Duostories: improve your Duolingo learning with community translated Duolingo stories.", description: "Supplement your Duolingo course with community-translated Duolingo stories.", alternates: { canonical: "https://duostories.org", }, keywords: [ "language", "learning", "stories", "Duolingo", "community", "volunteers", ], openGraph: { title: "Duostories", description: "Supplement your Duolingo course with community-translated Duolingo stories.", type: "website", url: `https://duostories.org`, }, }; export default function Layout({ children }: { children: React.ReactNode }) { return (
    {children}
    ); } ================================================ FILE: src/app/(stories)/(main)/not-found.tsx ================================================ import Header from "./header"; import Link from "next/link"; export default function NotFound() { return (

    Page Not Found

    This Page does not exist.

    Go back to the main page.

    ); } ================================================ FILE: src/app/(stories)/(main)/page.tsx ================================================ import Link from "next/link"; import Header from "./header"; import CourseList from "./course_list"; import Icons from "./icons"; import React from "react"; import { preloadQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; import LandingStatsClient from "./landing_stats_client"; export default async function Page({}) { const preloadedLandingData = await preloadQuery( api.landing.getPublicLandingPageData, {}, ); return ( <>

    Unofficial Duolingo Stories

    A community project to bring the original{" "} Duolingo Stories{" "} to new languages.

    If you want to contribute or discuss the stories, meet us on{" "} Discord
    or learn more about the project in our FAQ.

    ); } ================================================ FILE: src/app/(stories)/(main)/privacy_policy/page.tsx ================================================ import React from "react"; import Link from "next/link"; import { Metadata } from "next"; export const metadata: Metadata = { title: "Duostories Privacy Policy", description: "Privacy information for the duostories project.", alternates: { canonical: "https://duostories.org/privacy_policy", }, }; export default async function Page() { return (

    Privacy Policy for duostories.org

    Effective Date: Nov 16, 2023

    Welcome to duostories.org!

    {`Your privacy is critically important to us. At duostories.org, we are committed to protecting your personal data and ensuring transparency about how it's used. This Privacy Policy outlines the types of information we collect, how it's used, and the measures we take to protect it. If you have any questions, contact us on `} Discord.

    1. Data Collection:

    When you register on duostories.org, we collect the following information:

    • Username: To create a unique identity on our platform.
    • Email Address: For account verification, communication, and password recovery.
    • Hashed Password: To securely manage access to your account.

    For both registered and non-registered users, we track story completion to analyze readership trends and improve our services. This data is collected anonymously for users who are not logged in.

    2. Use of Data:

    The data we collect is used for the following purposes:

    • To analyze the popularity and readership of our stories.
    • To provide registered users with a personalized track of stories they have read.

    3. Consent and User Choice:

    By registering on duostories.org, you consent to the collection and use of your personal data as described in this policy. If you choose to use our website anonymously, we only collect non-personal data related to story completions.

    4. Data Storage and Security:

    Your personal data is stored in a secure MySQL database. We use advanced security measures, including password hashing with salt, to protect your data from unauthorized access.

    5. User Rights:

    You have the right to request the deletion of your personal data. To do so, please contact us via our Discord channel at{" "} https://discord.gg/4NGVScARR3 . We will typically delete your username and email address upon request.

    6. Changes to the Privacy Policy:

    We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Effective Date" at the top.

    7. Contact Information:

    For any questions or concerns regarding your privacy, please contact us at:

    • Discord:{" "} https://discord.gg/4NGVScARR3
    • Email:{" "} google.compel855@passinbox.com

    8. Use of Cookies and Tracking:

    At duostories.org, we value your privacy and aim for transparency in all our data practices. In line with this commitment:

    • Use of Cookies: We use cookies solely for the purpose of maintaining user sessions. These cookies enable you to stay logged in to your account, providing a seamless experience as you navigate through our stories.
    • No Cross-Website Tracking: We do not engage in cross-website tracking or analytics. Your activity on duostories.org is not monitored across other websites.
    • No Third-Party Cookies: We do not use third-party cookies. All cookies on our site are strictly limited to the functionalities of duostories.org and enhancing your user experience.

    You can manage cookies through your browser settings, though please note that disabling cookies may impact your experience on our site.


    Thank you for being a part of duostories.org. We are committed to protecting your privacy and creating a safe and enjoyable experience for all of our users.

    ); } ================================================ FILE: src/app/(stories)/(main)/profile/actions.ts ================================================ "use server"; import { fetchAuthMutation } from "@/lib/auth-server"; import { cookies } from "next/headers"; import { HIDE_STORY_QUESTIONS_COOKIE } from "@/lib/story-preferences"; import { api } from "@convex/_generated/api"; const STORY_PREFERENCE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; export async function setHideStoryQuestionsPreference(hideQuestions: boolean) { await fetchAuthMutation(api.userPreferences.setCurrentStoryPreferences, { hideStoryQuestions: hideQuestions, }); const cookieStore = await cookies(); cookieStore.set(HIDE_STORY_QUESTIONS_COOKIE, hideQuestions ? "1" : "0", { path: "/", maxAge: STORY_PREFERENCE_COOKIE_MAX_AGE, sameSite: "lax", secure: process.env.NODE_ENV === "production", }); } export async function deleteCurrentUserAccount() { await fetchAuthMutation(api.account.deleteCurrentUser, {}); } ================================================ FILE: src/app/(stories)/(main)/profile/data.ts ================================================ import { cookies } from "next/headers"; import { fetchAuthQuery } from "@/lib/auth-server"; import { HIDE_STORY_QUESTIONS_COOKIE, isStoryQuestionsDisabled, } from "@/lib/story-preferences"; import { getUser, isAdmin, isContributor } from "@/lib/userInterface"; import { api } from "@convex/_generated/api"; export interface ProfileData { name: string; username: string; email: string; image: string | null; role: string[]; provider_linked: Record; hide_story_questions: boolean; } export async function getProfileData() { const providersBase = ["facebook", "github", "google", "discord"]; const user = await getUser(); if (!user) return undefined; if (!user.email) throw new Error("No user email available"); const cookieStore = await cookies(); const [providersFromAuth, storyPreferences] = await Promise.all([ fetchAuthQuery(api.auth.getLinkedProvidersForCurrentUser, {}) as Promise< string[] >, fetchAuthQuery( api.userPreferences.getCurrentStoryPreferences, {}, ) as Promise<{ hasSavedPreference: boolean; hideStoryQuestions: boolean; }>, ]); const providerLinked = Object.fromEntries( providersBase.map((provider) => [provider, false]), ) as Record; for (const provider of providersFromAuth) { if (provider in providerLinked) { providerLinked[provider] = true; } } const role = []; if (isAdmin(user)) role.push("Admin"); if (isContributor(user)) role.push("Contributor"); const displayName = user.name ?? user.username ?? user.email.split("@")[0] ?? "User"; const username = user.username ?? displayName; return { name: displayName, username, email: user.email, image: user.image ?? null, role, provider_linked: providerLinked, hide_story_questions: storyPreferences.hasSavedPreference ? storyPreferences.hideStoryQuestions : isStoryQuestionsDisabled( cookieStore.get(HIDE_STORY_QUESTIONS_COOKIE)?.value, ), } satisfies ProfileData; } ================================================ FILE: src/app/(stories)/(main)/profile/page.tsx ================================================ import Header from "../header"; import Profile from "./profile"; import { Metadata } from "next"; import { getProfileData } from "./data"; export const metadata: Metadata = { alternates: { canonical: "https://duostories.org/profile", }, }; export default async function Page() { const providers = await getProfileData(); if (providers === undefined) { return (

    Not Logged in

    You need to be logged in to see your profile.

    ); } return ( <> ); } ================================================ FILE: src/app/(stories)/(main)/profile/profile.tsx ================================================ "use client"; import React from "react"; import Link from "next/link"; import Header from "../header"; import Button from "@/components/ui/button"; import Input from "@/components/ui/input"; import Switch from "@/components/ui/switch"; import { GetIcon } from "@/components/icons"; import { authClient } from "@/lib/auth-client"; import { resetPostHogUser } from "@/lib/posthog-user"; import type { ProfileData } from "./data"; import { deleteCurrentUserAccount, setHideStoryQuestionsPreference, } from "./actions"; const pageShellClass = "mx-auto mb-10 max-w-[860px] rounded-[28px] border border-[color:color-mix(in_srgb,var(--header-border)_60%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_94%,white)] p-4 shadow-[0_18px_56px_color-mix(in_srgb,#000_10%,transparent)] sm:p-6"; const cardClass = "rounded-[22px] border border-[color:color-mix(in_srgb,var(--header-border)_55%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_88%,transparent)] p-5"; const rowClass = "rounded-[18px] border border-[color:color-mix(in_srgb,var(--header-border)_38%,transparent)] bg-[color:color-mix(in_srgb,var(--body-background)_72%,var(--body-background-faint))] px-4 py-4"; const eyebrowClass = "text-[0.72rem] font-bold uppercase tracking-[0.18em] text-[var(--title-color-dim)]"; const labelClass = "mb-1 block text-[0.82rem] font-bold uppercase tracking-[0.08em] text-[var(--title-color-dim)]"; const successMessageClass = "mt-2 block text-[var(--button-border)]"; const errorMessageClass = "mt-2 block text-[var(--error-red)]"; function roleBadgeTone(role: string) { if (role === "Admin") { return "border-[color:color-mix(in_srgb,#ff9b55_60%,var(--header-border))] bg-[color:color-mix(in_srgb,#ff9b55_14%,transparent)]"; } return "border-[color:color-mix(in_srgb,var(--button-blue-background)_45%,var(--header-border))] bg-[color:color-mix(in_srgb,var(--button-blue-background)_12%,transparent)]"; } function StatusText({ state, error, success, dataCy, }: { state: "idle" | "pending" | "success" | "error"; error?: string; success?: string; dataCy?: string; }) { if (state === "error" && error) { return {error}; } if (state === "success" && success) { return ( {success} ); } return null; } function SettingRow({ label, value, helper, action, children, }: { label: string; value: React.ReactNode; helper?: React.ReactNode; action?: React.ReactNode; children?: React.ReactNode; }) { return (

    {label}

    {value}
    {helper ? (
    {helper}
    ) : null}
    {action ?
    {action}
    : null}
    {children ? (
    {children}
    ) : null}
    ); } function LinkedAccountRow({ provider, linked, }: { provider: string; linked: boolean; }) { const [linkError, setLinkError] = React.useState(null); const handleLink = async () => { setLinkError(null); const { data, error } = await authClient.linkSocial({ provider, callbackURL: window.location.href, }); if (error) { setLinkError(error.message || "Could not link account."); return; } if (data?.url) { window.location.href = data.url; return; } window.location.reload(); }; return (

    {provider}

    {linked ? "Linked for sign in." : "Available to link."}

    {linked ? ( Linked ) : ( )}
    {linkError ? ( {linkError} ) : null}
    ); } function ProfileAvatar({ image, username, }: { image: string | null; username: string; }) { const initial = username.slice(0, 1).toUpperCase(); const [imageFailed, setImageFailed] = React.useState(false); const showImage = typeof image === "string" && image.length > 0 && !imageFailed; if (showImage) { return ( {`${username} setImageFailed(true)} /> ); } return (
    {initial}
    ); } export default function Profile({ providers }: { providers: ProfileData }) { const { data: session } = authClient.useSession(); const sessionUser = session?.user as | { name?: string | null; image?: string | null; } | undefined; const initialUsername = providers.username || providers.name; const [username, setUsername] = React.useState(initialUsername); const [newEmail, setNewEmail] = React.useState(""); const [resetState, setResetState] = React.useState< "idle" | "pending" | "success" | "error" >("idle"); const [resetError, setResetError] = React.useState(""); const [emailState, setEmailState] = React.useState< "idle" | "pending" | "success" | "error" >("idle"); const [emailError, setEmailError] = React.useState(""); const [pendingEmailChange, setPendingEmailChange] = React.useState(""); const [usernameState, setUsernameState] = React.useState< "idle" | "pending" | "success" | "error" >("idle"); const [usernameError, setUsernameError] = React.useState(""); const [savedUsername, setSavedUsername] = React.useState(initialUsername); const [isEditingUsername, setIsEditingUsername] = React.useState(false); const [isEditingEmail, setIsEditingEmail] = React.useState(false); const [isShowingPasswordReset, setIsShowingPasswordReset] = React.useState(false); const [hideStoryQuestions, setHideStoryQuestions] = React.useState( providers.hide_story_questions, ); const [storyQuestionsState, setStoryQuestionsState] = React.useState< "idle" | "pending" | "success" | "error" >("idle"); const [storyQuestionsError, setStoryQuestionsError] = React.useState(""); const [isShowingDeleteAccount, setIsShowingDeleteAccount] = React.useState(false); const [deleteConfirmation, setDeleteConfirmation] = React.useState(""); const [deleteState, setDeleteState] = React.useState< "idle" | "pending" | "error" >("idle"); const [deleteError, setDeleteError] = React.useState(""); const avatarName = sessionUser?.name?.trim() || savedUsername || providers.name || "U"; const avatarImage = sessionUser?.image ?? providers.image; const deleteConfirmationTarget = savedUsername.trim() || providers.email; React.useEffect(() => { const storedPendingEmail = window.localStorage.getItem( "profile_pending_email_change", ); if (!storedPendingEmail) return; if (storedPendingEmail.toLowerCase() === providers.email.toLowerCase()) { window.localStorage.removeItem("profile_pending_email_change"); setPendingEmailChange(""); return; } setPendingEmailChange(storedPendingEmail); }, [providers.email]); function openUsernameEditor() { setUsername(savedUsername); setUsernameState("idle"); setUsernameError(""); setIsEditingUsername(true); } function closeUsernameEditor() { setUsername(savedUsername); setUsernameState("idle"); setUsernameError(""); setIsEditingUsername(false); } function openEmailEditor() { setNewEmail(""); setEmailState("idle"); setEmailError(""); setIsEditingEmail(true); } function closeEmailEditor() { setNewEmail(""); setEmailState("idle"); setEmailError(""); setIsEditingEmail(false); } function openDeleteAccount() { setDeleteConfirmation(""); setDeleteState("idle"); setDeleteError(""); setIsShowingDeleteAccount(true); } function closeDeleteAccount() { setDeleteConfirmation(""); setDeleteState("idle"); setDeleteError(""); setIsShowingDeleteAccount(false); } async function requestPasswordReset() { setResetState("pending"); setResetError(""); try { await authClient.requestPasswordReset({ email: providers.email, redirectTo: `${window.location.origin}/auth/reset_pw`, }); setResetState("success"); } catch (e) { setResetState("error"); setResetError((e as Error)?.message || "Could not send reset link."); } } async function requestEmailChange() { const emailValidation = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailValidation.test(newEmail)) { setEmailState("error"); setEmailError("Please enter a valid email address."); return; } if (newEmail.toLowerCase() === providers.email.toLowerCase()) { setEmailState("error"); setEmailError("This is already your current email address."); return; } setEmailState("pending"); setEmailError(""); const { error } = await authClient.changeEmail({ newEmail, callbackURL: `${window.location.origin}/profile`, }); if (error) { setEmailState("error"); setEmailError(error.message || "Could not start email change."); return; } window.localStorage.setItem("profile_pending_email_change", newEmail); setPendingEmailChange(newEmail); setEmailState("success"); } async function saveUsername() { const normalizedUsername = username.trim(); const usernameValidation = /^[a-zA-Z0-9_-]{3,20}$/; if (!usernameValidation.test(normalizedUsername)) { setUsernameState("error"); setUsernameError( "Username must be 3-20 characters and use only letters, numbers, _ or -.", ); return; } if (normalizedUsername === savedUsername) { setUsernameState("success"); setUsernameError(""); return; } setUsernameState("pending"); setUsernameError(""); const { error } = await authClient.updateUser({ username: normalizedUsername, name: normalizedUsername, }); if (error) { setUsernameState("error"); const errorCode = typeof (error as { code?: unknown })?.code === "string" ? (error as { code: string }).code : ""; const errorMessage = typeof error.message === "string" ? error.message : ""; const isUsernameTaken = errorCode === "USERNAME_IS_ALREADY_TAKEN" || errorMessage.toLowerCase().includes("username is already taken"); setUsernameError( isUsernameTaken ? "That username is already taken. Please choose another one." : errorMessage || "Could not update username.", ); return; } setSavedUsername(normalizedUsername); setUsernameState("success"); setUsernameError(""); } async function toggleHideStoryQuestions() { if (storyQuestionsState === "pending") return; const nextHideStoryQuestions = !hideStoryQuestions; setHideStoryQuestions(nextHideStoryQuestions); setStoryQuestionsState("pending"); setStoryQuestionsError(""); try { await setHideStoryQuestionsPreference(nextHideStoryQuestions); setStoryQuestionsState("success"); } catch (error) { setHideStoryQuestions(!nextHideStoryQuestions); setStoryQuestionsState("error"); setStoryQuestionsError( (error as Error)?.message || "Could not update your story question preference.", ); } } async function removeAccount() { if (deleteState === "pending") return; if (deleteConfirmation.trim() !== deleteConfirmationTarget) { setDeleteState("error"); setDeleteError(`Type ${deleteConfirmationTarget} to confirm.`); return; } const confirmed = window.confirm( "Delete your account now? This will remove your sign-in account and log you out.", ); if (!confirmed) { return; } setDeleteState("pending"); setDeleteError(""); try { await deleteCurrentUserAccount(); } catch (error) { setDeleteState("error"); setDeleteError( (error as Error)?.message || "Could not delete your account.", ); return; } window.localStorage.removeItem("profile_pending_email_change"); try { await authClient.signOut(); } catch {} resetPostHogUser(); window.location.href = "/"; } return ( <>

    Profile

    Manage your account details and sign-in methods.

    Account

    {savedUsername}

    {providers.email}

    {providers.role.length ? ( providers.role.map((role) => ( {role} )) ) : ( Standard user )}
    { if (isEditingUsername) { closeUsernameEditor(); } else { openUsernameEditor(); } }} > {isEditingUsername ? "Close" : "Edit"} } > {isEditingUsername ? (
    setUsername(e.target.value)} />
    ) : null}
    { if (isEditingEmail) { closeEmailEditor(); } else { openEmailEditor(); } }} > {isEditingEmail ? "Close" : "Edit"} } > {isEditingEmail ? (
    setNewEmail(e.target.value)} placeholder="New email address" data-cy="profile-new-email" /> {pendingEmailChange ? (
    Current email: {providers.email}. Pending change:{" "} {pendingEmailChange}.
    ) : null}
    ) : null}
    setIsShowingPasswordReset((current) => !current) } > {isShowingPasswordReset ? "Close" : "Reset"} } > {isShowingPasswordReset ? (
    ) : null}

    Stories

    Playback preferences

    Control how standard story pages behave when you open them.

    } >

    Connected accounts

    Social sign-in

    Link a provider to sign in without your password.

    {Object.entries(providers.provider_linked).map( ([provider, linked]) => ( ), )}

    Delete account

    Permanently delete your account

    Remove your Duostories account and sign out immediately. This action cannot be undone.

    {isShowingDeleteAccount ? (

    Confirm deletion

    Type {deleteConfirmationTarget} to confirm that you want to permanently delete this account.

    setDeleteConfirmation(e.target.value)} placeholder={deleteConfirmationTarget} autoCapitalize="none" autoCorrect="off" spellCheck={false} data-cy="profile-delete-confirmation" /> {deleteState === "error" ? ( {deleteError} ) : null}
    ) : null}
    ); } ================================================ FILE: src/app/(stories)/learn/page.tsx ================================================ import React from "react"; import { redirect } from "next/navigation"; import Welcome from "./welcome"; import { getUser } from "@/lib/userInterface"; import { fetchQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; export const metadata = { title: "Learn with Duostories", description: "Sign in to track your progress or continue anonymously and learn with Duostories.", alternates: { canonical: "https://duostories.org/learn", }, }; export default async function Page() { const user = await getUser(); if (user?.userId) { const lastCourseShort = await fetchQuery( api.storyDone.getLastDoneCourseShortForLegacyUser, { legacyUserId: user.userId, }, ); if (lastCourseShort) redirect("/" + lastCourseShort); } return ; } ================================================ FILE: src/app/(stories)/learn/welcome.tsx ================================================ "use client"; import Link from "next/link"; import React from "react"; import { buttonInnerClassName, buttonRootClassName, } from "@/components/ui/button"; export default function Page() { return (
    Duostories logo

    Learn with stories

    Welcome to Duostories

    Sign in to keep your reading progress, or continue anonymously and start learning right away.

    or
    Continue anonymously
    ); } ================================================ FILE: src/app/(stories)/story/[story_id]/auto_play/page.tsx ================================================ import React from "react"; import StoryWrapper from "./story_wrapper"; import { notFound } from "next/navigation"; import { fetchQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; export async function generateMetadata({ params, }: { params: { story_id: string }; }) { const story_id = parseInt((await params).story_id); const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, { storyId: story_id, }); if (!story) notFound(); return { title: `${story.from_language_name} - Duostories ${story.learning_language_long} from ${story.from_language_long}`, alternates: { canonical: `https://duostories.org/story/${story_id}/auto_play`, }, keywords: [story.learning_language_long], openGraph: { images: [ `/api/og-story?title=${story.from_language_name}&image=${story.image}&name=${story.learning_language_long}`, ], url: `https://duostories.org/story/${story_id}/auto_play`, type: "website", }, }; } export default async function Page({ params, }: { params: Promise<{ story_id: string }>; }) { const story_id = parseInt((await params).story_id); if (!Number.isFinite(story_id)) notFound(); return ; } ================================================ FILE: src/app/(stories)/story/[story_id]/auto_play/story_wrapper.tsx ================================================ "use client"; import React from "react"; import StoryAutoPlay from "@/components/StoryAutoPlay"; import { useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; export default function StoryWrapper({ storyId }: { storyId: number }) { const story = useQuery(api.storyRead.getStoryByLegacyId, { storyId }); if (story === undefined) return null; if (story === null) return

    Story not found.

    ; return ; } ================================================ FILE: src/app/(stories)/story/[story_id]/getStory.ts ================================================ import { fetchQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; export async function get_story(story_id: number) { return await fetchQuery(api.storyRead.getStoryByLegacyId, { storyId: story_id, }); } export type StoryData = NonNullable>>; ================================================ FILE: src/app/(stories)/story/[story_id]/loading.tsx ================================================ import React from "react"; import StoryHeaderProgress from "@/components/StoryHeaderProgress"; import { Spinner } from "@/components/ui/spinner"; export default function Loading() { return ( <>

    Loading Story...

    ); } ================================================ FILE: src/app/(stories)/story/[story_id]/not-found.tsx ================================================ import Link from "next/link"; import Header from "../../(main)/header"; export default function NotFound() { return (

    Story Not Found

    This story does not exist or is not published yet.

    Go back to the main page.

    ); } ================================================ FILE: src/app/(stories)/story/[story_id]/page.tsx ================================================ import React, { Suspense } from "react"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { fetchAuthQuery } from "@/lib/auth-server"; import getUserId from "@/lib/getUserId"; import { HIDE_STORY_QUESTIONS_COOKIE, isStoryQuestionsDisabled, } from "@/lib/story-preferences"; import StoryWrapper from "./story_wrapper"; import { get_story } from "./getStory"; import LocalisationProvider from "@/components/LocalisationProvider"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@convex/_generated/api"; import { fetchQuery } from "convex/nextjs"; import { fetchAuthMutation } from "@/lib/auth-server"; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? process.env.CONVEX_URL ?? ""; if (!convexUrl) { throw new Error("Missing NEXT_PUBLIC_CONVEX_URL/CONVEX_URL"); } const convex = new ConvexHttpClient(convexUrl); export async function generateMetadata({ params, }: { params: Promise<{ story_id: string }>; }) { const story_id = parseInt((await params).story_id); const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, { storyId: story_id, }); if (!story) notFound(); return { title: `${story.from_language_name} - Duostories ${story.learning_language_long} from ${story.from_language_long}`, alternates: { canonical: `https://duostories.org/story/${story_id}`, }, keywords: [story.learning_language_long], openGraph: { images: [ `/api/og-story?title=${story.from_language_name}&image=${story.image}&name=${story.learning_language_long}`, ], url: `https://duostories.org/story/${story_id}`, type: "website", }, }; } export default async function Page({ params, }: { params: Promise<{ story_id: string }>; }) { const cookieStore = await cookies(); const story_id = parseInt((await params).story_id); const story = await get_story(story_id); if (!story) notFound(); const course_id = story.course_id; const user_id = await getUserId(); const cookieHideStoryQuestions = isStoryQuestionsDisabled( cookieStore.get(HIDE_STORY_QUESTIONS_COOKIE)?.value, ); const savedStoryPreferences = user_id ? ((await fetchAuthQuery( api.userPreferences.getCurrentStoryPreferences, {}, )) as { hasSavedPreference: boolean; hideStoryQuestions: boolean; }) : null; const hideStoryQuestions = savedStoryPreferences?.hasSavedPreference === true ? savedStoryPreferences.hideStoryQuestions : cookieHideStoryQuestions; async function setStoryDoneAction() { "use server"; if (!user_id) { await convex.mutation(api.storyDone.recordStoryDone, { legacyStoryId: story_id, time: Date.now(), }); return { message: "done", story_id: story_id, }; } await fetchAuthMutation(api.storyDone.recordStoryDone, { legacyStoryId: story_id, time: Date.now(), }); return { message: "done", story_id: story_id, course_id: course_id, }; } return ( <> ); } ================================================ FILE: src/app/(stories)/story/[story_id]/script/page.tsx ================================================ import React from "react"; import { notFound } from "next/navigation"; import StoryWrapper from "./story_wrapper"; import { get_story } from "../getStory"; import LocalisationProvider from "@/components/LocalisationProvider"; import { headers } from "next/headers"; import { fetchQuery } from "convex/nextjs"; import { api } from "@convex/_generated/api"; export async function generateMetadata({ params, }: { params: Promise<{ story_id: string }>; }) { const story_id = parseInt((await params).story_id); const story = await fetchQuery(api.storyRead.getStoryMetaByLegacyId, { storyId: story_id, }); if (!story) notFound(); return { title: `Duostories ${story.learning_language_long} from ${story.from_language_long}: ${story.from_language_name}`, alternates: { canonical: `https://duostories.org/story/${story_id}`, }, keywords: [story.learning_language_long], }; } async function getNavigationMode() { const headersList = await headers(); // If there is a next-url header, soft navigation has been performed // Otherwise, hard navigation has been performed const nextUrl = headersList.get("next-url"); if (nextUrl) { return "soft"; } return "hard"; } export default async function Page({ params, }: { params: Promise<{ story_id: string }>; }) { const story_id = parseInt((await params).story_id); const story = await get_story(story_id); if (!story) notFound(); async function setStoryDoneAction() { "use server"; return { message: "done", }; } return ( <> ); } ================================================ FILE: src/app/(stories)/story/[story_id]/script/story_wrapper.tsx ================================================ "use client"; import React from "react"; import StoryProgress from "@/components/StoryProgress"; import { StoryData } from "@/app/(stories)/story/[story_id]/getStory"; export default function StoryWrapper({ story, storyFinishedIndexUpdate, show_title_page, }: { story: StoryData; storyFinishedIndexUpdate: () => Promise<{ message: string }>; show_title_page: boolean; }) { const [highlight_name, setHighlightName] = React.useState([]); const [hideNonHighlighted, setHideNonHighlighted] = React.useState(false); //console.log("highlight_nameX", highlight_name); return ( <> {}, show_audio: true, setShowAudio: () => {}, id: story.id, show_title_page: show_title_page, }} onEnd={storyFinishedIndexUpdate} /> ); } ================================================ FILE: src/app/(stories)/story/[story_id]/story_wrapper.tsx ================================================ "use client"; import React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery } from "convex/react"; import StoryProgress from "@/components/StoryProgress"; import { useNavigationMode } from "@/components/NavigationModeProvider"; import { StoryData } from "@/app/(stories)/story/[story_id]/getStory"; import { api } from "@convex/_generated/api"; import posthog from "posthog-js"; import { authClient } from "@/lib/auth-client"; import { getCurrentPostHogUser, identifyPostHogUser, type PostHogUser, } from "@/lib/posthog-user"; export default function StoryWrapper({ story, hideStoryQuestions, storyFinishedIndexUpdate, }: { story: StoryData; hideStoryQuestions: boolean; storyFinishedIndexUpdate: () => Promise< | { message: string; story_id: number; course_id?: undefined; } | { message: string; story_id: number; course_id: number; } >; }) { const mode = useNavigationMode(); const router = useRouter(); const searchParams = useSearchParams(); const [highlight_name, setHighlightName] = React.useState([]); const [hideNonHighlighted, setHideNonHighlighted] = React.useState(false); const trackedStoryStart = React.useRef(false); const completionInFlight = React.useRef(false); const { data: session } = authClient.useSession(); const sessionUser = (session?.user ?? null) as PostHogUser | null; const role = typeof sessionUser?.role === "string" ? sessionUser.role : null; const editHrefBase = role === "contributor" || role === "admin" ? `/editor/course/${story.course_short}/story/${story.id}` : undefined; const rawLine = searchParams.get("line"); const parsedLine = typeof rawLine === "string" ? Number(rawLine) : undefined; const initialFocusLine = parsedLine !== undefined && Number.isFinite(parsedLine) && parsedLine > 0 ? parsedLine : undefined; const nextStep = useQuery( api.storyDone.getNextStoryForCurrentUserInCourse, sessionUser?.id ? { courseShort: story.course_short, currentStoryId: story.id, } : "skip", ); const showNextStoryAction = Boolean(nextStep?.nextStoryId); const nextStoryPreview = useQuery( api.storyRead.getStoryPreviewByLegacyId, showNextStoryAction && nextStep?.nextStoryId ? { storyId: nextStep.nextStoryId } : "skip", ); const captureStoryEvent = React.useCallback( async (eventName: "story_started" | "story_completed") => { if (!identifyPostHogUser(sessionUser)) { identifyPostHogUser(await getCurrentPostHogUser()); } posthog.capture(eventName, { story_id: story.id, story_name: story.from_language_name, course_id: story.course_id, course_short: story.course_short, learning_language: story.learning_language_long, }); }, [ sessionUser, story.id, story.from_language_name, story.course_id, story.course_short, story.learning_language_long, ], ); // Track story started on component mount React.useEffect(() => { if (trackedStoryStart.current) return; trackedStoryStart.current = true; void captureStoryEvent("story_started"); }, [captureStoryEvent]); const finishedLabel = showNextStoryAction ? "Next story" : nextStep && !nextStep.nextStoryId ? "Review stories" : undefined; async function completeStoryOnce() { if (completionInFlight.current) return false; completionInFlight.current = true; let succeeded = false; try { await captureStoryEvent("story_completed"); await storyFinishedIndexUpdate(); succeeded = true; return true; } finally { if (!succeeded) { completionInFlight.current = false; } } } async function goToOverview() { const didComplete = await completeStoryOnce(); if (!didComplete) return; navigateToOverview(); } function navigateToOverview() { const setHash = story.set_id > 0 ? `#${story.set_id}` : ""; router.push(`/${story.course_short}${setHash}`); } async function onEnd() { const didComplete = await completeStoryOnce(); if (!didComplete) return; if (showNextStoryAction && nextStep?.nextStoryId) { posthog.capture("story_end_next_clicked", { language: story.learning_language_long, story_id: nextStep.nextStoryId, completed_count: nextStep.completedCount, total_count: nextStep.totalCount, }); router.push(`/story/${nextStep.nextStoryId}`); return; } navigateToOverview(); } const shouldShowDefaultFinishedButton = !sessionUser?.id || nextStep === undefined || nextStep === null; const showFinishedPrimaryAction = shouldShowDefaultFinishedButton || Boolean(finishedLabel); return ( <> {}, show_audio: true, setShowAudio: () => {}, id: story.id, show_title_page: mode === "hard", }} onEnd={onEnd} onBackToOverview={goToOverview} finishedLabel={finishedLabel} nextStoryPreview={nextStoryPreview} showFinishedPrimaryAction={showFinishedPrimaryAction} /> ); } ================================================ FILE: src/app/(stories)/story/[story_id]/test/page.tsx ================================================ import React from "react"; import StoryWrapper from "./story_wrapper"; import { notFound } from "next/navigation"; export default async function Page({ params, }: { params: Promise<{ story_id: string }>; }) { const story_id = parseInt((await params).story_id); if (!Number.isFinite(story_id)) notFound(); return ; } ================================================ FILE: src/app/(stories)/story/[story_id]/test/story_wrapper.tsx ================================================ "use client"; import React from "react"; import { useSearchParams } from "next/navigation"; import StoryProgress from "@/components/StoryProgress"; import { useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; export default function StoryWrapper({ storyId }: { storyId: number }) { const hide_questions = useSearchParams().get("hide_questions"); const story = useQuery(api.storyRead.getStoryByLegacyId, { storyId }); if (story === undefined) return null; if (story === null) return

    Story not found.

    ; return ( <> {}} settings={{ hide_questions: !!hide_questions, show_all: true, show_names: false, rtl: story.learning_language_rtl, highlight_name: [], hideNonHighlighted: false, setHighlightName: (_name: string[]) => {}, setHideNonHighlighted: (_value: React.SetStateAction) => {}, show_hints: true, setShowHints: () => {}, show_audio: true, setShowAudio: () => {}, id: story.id, show_title_page: false, }} /> ); } ================================================ FILE: src/app/(stories)/story/layout.tsx ================================================ import "@/styles/global.css"; export const metadata = { title: "Duostories: improve your Duolingo learning with community translated Duolingo stories.", description: "Supplement your Duolingo course with community-translated Duolingo stories.", alternates: { canonical: "https://duostories.org", }, keywords: [ "language", "learning", "stories", "Duolingo", "community", "volunteers", ], }; export default function Layout({ children }: { children: React.ReactNode }) { return children; } ================================================ FILE: src/app/admin/AdminDialogTrigger.tsx ================================================ "use client"; import type { ReactNode } from "react"; import { buttonInnerClassName, buttonRootClassName, } from "@/components/ui/button"; import * as EditDialog from "./edit_dialog"; interface AdminDialogTriggerProps { children: ReactNode; isNew?: boolean; onOpenChange: (open: boolean) => void; open: boolean; } export default function AdminDialogTrigger({ children, isNew, onOpenChange, open, }: AdminDialogTriggerProps) { return ( {children} ); } ================================================ FILE: src/app/admin/AdminHeader.tsx ================================================ import Link from "next/link"; import React from "react"; import { requireAdmin } from "@/lib/userInterface"; import EditorCommandPalette from "@/app/editor/_components/editor_command_palette"; import { LoggedInButtonWrappedClient } from "@/components/login/LoggedInButtonWrappedClient"; const adminButtonClassName = "flex min-w-[105px] cursor-pointer flex-row items-center px-3.5 text-[var(--text-color-dim)] no-underline hover:brightness-75 hover:contrast-[2.5] hover:text-[var(--text-color)] max-[1120px]:w-auto max-[1120px]:min-w-0 max-[1120px]:flex-col max-[1120px]:px-2 max-[760px]:min-w-fit max-[760px]:flex-row max-[760px]:px-2.5"; const adminNavClassName = "sticky top-0 z-[200] box-border flex h-[60px] w-full flex-row items-center overflow-x-auto overflow-y-hidden border-b-2 border-[var(--header-border)] bg-[var(--body-background)] px-5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden [&>*]:flex-none max-w-[100vw] max-[1120px]:px-3 max-[760px]:h-auto max-[760px]:min-h-14 max-[760px]:gap-1 max-[760px]:py-2"; function AdminButton({ children, href, ...delegated }: { children: React.ReactNode; href: string; } & React.HTMLAttributes) { return (
    import button
    {children} ); } export default async function AdminHeader() { await requireAdmin(); return ( ); } ================================================ FILE: src/app/admin/FlagName.tsx ================================================ import React from "react"; import Flag from "@/components/ui/flag"; export default function FlagName({ lang, languages, }: { lang: number; languages: Record< number, { short: string; flag: number | null; flag_file: string | null; name: string | null; } >; }) { return (
    {languages[lang].name}
    ); } ================================================ FILE: src/app/admin/adminDetailStyles.ts ================================================ export const adminDetailPageClass = "mx-auto my-6 mb-10 w-[min(860px,calc(100vw-32px))]"; export const adminDetailCardClass = "rounded-2xl border border-[color:color-mix(in_srgb,var(--header-border)_70%,transparent)] bg-[var(--body-background)] p-5 shadow-[0_16px_38px_color-mix(in_srgb,#000_14%,transparent)]"; export const adminDetailLabelClass = "text-left text-[var(--text-color-dim)] md:text-right"; ================================================ FILE: src/app/admin/adminTableStyles.ts ================================================ export const adminTableContainerClass = "relative isolate overflow-auto rounded-xl border border-[color:color-mix(in_srgb,var(--header-border)_60%,transparent)]"; export const adminTableHeadCellClass = "sticky top-0 z-[1] bg-[color:color-mix(in_srgb,var(--button-background)_88%,#fff)] px-3 py-2 text-left text-sm uppercase tracking-wide text-[var(--button-color)]"; ================================================ FILE: src/app/admin/courses/courses.tsx ================================================ "use client"; import Link from "next/link"; import { Spinner } from "@/components/ui/spinner"; import Flag from "@/components/ui/flag"; import * as EditDialog from "../edit_dialog"; import React, { useState } from "react"; import Button from "@/components/ui/button"; import Tag from "@/components/ui/badge"; import Input from "@/components/ui/input"; import FlagName from "../FlagName"; import AdminDialogTrigger from "../AdminDialogTrigger"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { adminTableContainerClass, adminTableHeadCellClass, } from "../adminTableStyles"; import { useMutation } from "convex/react"; import { api } from "@convex/_generated/api"; interface CourseProps { id: number; learning_language: number; from_language: number; public: boolean; official: boolean; name: string | null; about: string | null; conlang: boolean; short: string | null; tags: string[]; } interface AdminLanguageProps { id: number; name: string; short: string; flag: number; flag_file: string; speaker: string; default_text: string; tts_replace: string; public: boolean; rtl: boolean; } const statusYesClass = "inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#21c55d_22%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#0a6b2d]"; const statusNoClass = "inline-block min-w-10 rounded-full bg-[color:color-mix(in_srgb,#ef4444_20%,transparent)] px-2.5 py-0.5 text-center text-sm font-bold text-[#9b1c1c]"; function InputLanguage({ name, label, value, setValue, languages, }: { name: string; label: string; value: number; setValue: (value: number) => void; languages: Record; }) { const [nameX, setName] = useState(languages[value]?.name || ""); const inputRef = React.useRef(null); let valid = false; for (const lang in languages) { if (languages[lang].name.toLowerCase() === nameX.toLowerCase()) { valid = true; break; } } const edited = function (e: React.ChangeEvent | string) { let value = typeof e == "string" ? e : e.target?.value; for (const lang in languages) { if ( value?.toLowerCase && languages[lang].name.toLowerCase() === value.toLowerCase() ) { setValue(parseInt(lang)); //props.callback(props.name, lang); break; } } setName(value); }; const language_id: number[] = []; for (const key in languages) { const lang = Number(key); if (languages[lang].name.toLowerCase().indexOf(nameX.toLowerCase()) !== -1) language_id.push(lang); } return ( {name}
    {valid ? ( ) : ( )}
    {language_id.map((lang) => ( ))}
    ); } function EditCourse({ obj, languages, updateCourse, is_new, onShortcutClose, shortcutOpen, }: { obj: CourseProps; languages: Record; updateCourse: (course: CourseProps) => void; is_new: boolean; onShortcutClose?: () => void; shortcutOpen?: boolean; }) { const [open, setOpen] = useState(false); const [error, setError] = useState(undefined); const createCourseMutation = useMutation(api.adminWrite.createAdminCourse); const updateCourseMutation = useMutation(api.adminWrite.updateAdminCourse); const [short, setShort] = useState(obj.short || ""); const [fromLanguage, setFromLanguage] = useState(obj.from_language || 0); const [learningLanguage, setLearningLanguage] = useState( obj.learning_language || 0, ); const [name, setName] = useState(obj.name || ""); const [published, setPublished] = useState(obj.public || false); const [conlang, setConlang] = useState(obj.conlang || false); const [tags, setTags] = useState(obj.tags || []); const [about, setAbout] = useState(obj.about || ""); React.useEffect(() => { if (shortcutOpen) { setOpen(true); } }, [shortcutOpen]); function handleOpenChange(nextOpen: boolean) { setOpen(nextOpen); if (!nextOpen && shortcutOpen) { onShortcutClose?.(); } } async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const tagList = tags.map((t) => t.trim().toLowerCase()).filter(Boolean); const data = { id: obj.id, from_language: fromLanguage, learning_language: learningLanguage, name: name, public: published, conlang: conlang, tags: tagList, about: about, }; //console.log("send", data); try { let new_data; if (is_new) { new_data = await createCourseMutation({ learning_language: data.learning_language, from_language: data.from_language, public: data.public, name: data.name, conlang: data.conlang, tags: data.tags, about: data.about, operationKey: `course:create:${data.learning_language}:${data.from_language}:client`, }); } else { new_data = await updateCourseMutation({ id: data.id, learning_language: data.learning_language, from_language: data.from_language, public: data.public, name: data.name, conlang: data.conlang, tags: data.tags, about: data.about, operationKey: `course:${data.id}:admin_set:client`, }); } //console.log("new_data", new_data); setOpen(false); if (shortcutOpen) { onShortcutClose?.(); } updateCourse(new_data); } catch (e) { //console.log("error", e); setError("An error occurred. Please report in Discord."); } } return ( {is_new ? "Add" : "Edit"} course {is_new ? "Add a new course. Click save when you're done." : "Make changes to a course. Click save when you're done."}
    setTags(t.split(",").map((t) => t.trim()))} />
    {error ? (
    An error occurred.
    ) : (
    )}
    ); } function TableRow({ course, languages, updateCourse, isShortcutOpen, onShortcutClose, }: { course: CourseProps; languages: Record; updateCourse: (course: CourseProps) => void; isShortcutOpen?: boolean; onShortcutClose?: () => void; }) { const refRow = React.useRef(null); function updateCourseWrapper(new_course: CourseProps) { const frames = [ { opacity: 0, filter: "blur(10px) saturate(0)" }, { opacity: 1, filter: "" }, ]; const attributes: (keyof CourseProps)[] = [ "id", "short", "learning_language", "from_language", "public", "name", "conlang", "tags", "about", ]; function check_equal(attribute: keyof CourseProps) { if (attribute === "tags") { return ( new_course[attribute].sort().join(",") === course[attribute].sort().join(",") ); } return new_course[attribute] === course[attribute]; } for (let i = 0; i < attributes.length; i++) { if (!check_equal(attributes[i])) { /*console.log( "update", attributes[i], new_course[attributes[i]], course[attributes[i]], );*/ if (refRow.current) refRow.current.children[i].animate(frames, { duration: 1000, iterations: 1, }); } } updateCourse(new_course); } return ( {course.id} {{course.short}} {course.public ? "Yes" : "No"} {course.name} {course.conlang ? "Yes" : "No"}
    {course.tags.map((d) => ( {d} ))}
    {course.about}
    ); } export function CourseList({ all_courses, languages, }: { all_courses: CourseProps[]; languages: AdminLanguageProps[]; }) { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const [search, setSearch] = React.useState(""); const [my_courses, setMyCourses] = React.useState(all_courses); const editCourseValue = searchParams.get("editCourse"); const editCourseId = editCourseValue ? Number.parseInt(editCourseValue, 10) : Number.NaN; const addCourseShortcut = searchParams.get("addCourse") === "1"; function clearShortcut() { const nextParams = new URLSearchParams(searchParams.toString()); nextParams.delete("editCourse"); nextParams.delete("addCourse"); const nextUrl = nextParams.size > 0 ? `${pathname}?${nextParams}` : pathname; router.replace(nextUrl, { scroll: false }); } React.useEffect(() => { setMyCourses(all_courses); }, [all_courses]); function updateCourse(course: CourseProps) { setMyCourses(my_courses.map((c) => (c.id === course.id ? course : c))); } if (languages === undefined || my_courses === undefined) return ; const languages_id: Record = {}; for (const l of languages) languages_id[l.id] = l; let filtered_courses: CourseProps[] = []; if (search === "") filtered_courses = my_courses; else { for (const course of my_courses) { if (!languages_id[course.learning_language]) continue; if ( languages_id[course.learning_language].name .toLowerCase() .indexOf(search.toLowerCase()) !== -1 || languages_id[course.from_language].name .toLowerCase() .indexOf(search.toLowerCase()) !== -1 ) { filtered_courses.push(course); } } } return (
    setSearch(e.target.value)} />
    {filtered_courses.map((course) => ( ))}
    learning_language from_language public name conlang tags about
    ); } ================================================ FILE: src/app/admin/courses/page.tsx ================================================ import CourseListClient from "./page_client"; export default function Page() { return ; } ================================================ FILE: src/app/admin/courses/page_client.tsx ================================================ "use client"; import { useQuery } from "convex/react"; import { api } from "@convex/_generated/api"; import { CourseList } from "./courses"; import { Spinner } from "@/components/ui/spinner"; export default function CourseListClient() { const data = useQuery(api.adminData.getAdminCourses, {}); if (data === undefined) return ; return ; } ================================================ FILE: src/app/admin/edit_dialog.tsx ================================================ import React from "react"; import { Dialog, DialogClose, DialogContent, DialogDescription as UiDialogDescription, DialogTitle as UiDialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import StandardButton from "@/components/ui/button"; import { cn } from "@/lib/utils"; export const Root = Dialog; export const Trigger = DialogTrigger; export function Content({ children }: { children: React.ReactNode }) { return (
    {children}
    ); } export function DialogTitle({ className, ...props }: React.ComponentProps) { return ; } export function DialogDescription({ className, ...props }: React.ComponentProps) { return ( ); } export const Button = React.forwardRef< HTMLButtonElement, React.ComponentProps >(function EditDialogButton(props, ref) { return ; }); export function Fieldset({ className, ...props }: React.FieldsetHTMLAttributes) { return (
    ); } export function Label({ className, ...props }: React.LabelHTMLAttributes) { return (